Merge branch 'cheeks-lab-day-binding-eval' of github.com:Budibase/budibase into cheeks-snippets-poc
This commit is contained in:
commit
b6eab42c18
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,126 +114,130 @@
|
||||||
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
|
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
|
||||||
class:builder={$builderStore.inBuilder}
|
class:builder={$builderStore.inBuilder}
|
||||||
>
|
>
|
||||||
<DeviceBindingsProvider>
|
{#if $environmentStore.maintenance.length > 0}
|
||||||
<UserBindingsProvider>
|
<MaintenanceScreen maintenanceList={$environmentStore.maintenance} />
|
||||||
<StateBindingsProvider>
|
{:else}
|
||||||
<RowSelectionProvider>
|
<DeviceBindingsProvider>
|
||||||
<QueryParamsProvider>
|
<UserBindingsProvider>
|
||||||
<SnippetsProvider>
|
<StateBindingsProvider>
|
||||||
<!-- Settings bar can be rendered outside of device preview -->
|
<RowSelectionProvider>
|
||||||
<!-- Key block needs to be outside the if statement or it breaks -->
|
<QueryParamsProvider>
|
||||||
{#key $builderStore.selectedComponentId}
|
<SnippetsProvider>
|
||||||
{#if $builderStore.inBuilder}
|
<!-- Settings bar can be rendered outside of device preview -->
|
||||||
<SettingsBar />
|
<!-- Key block needs to be outside the if statement or it breaks -->
|
||||||
{/if}
|
{#key $builderStore.selectedComponentId}
|
||||||
{/key}
|
{#if $builderStore.inBuilder}
|
||||||
|
<SettingsBar />
|
||||||
<!-- Clip boundary for selection indicators -->
|
|
||||||
<div
|
|
||||||
id="clip-root"
|
|
||||||
class:preview={$builderStore.inBuilder}
|
|
||||||
class:tablet-preview={$builderStore.previewDevice ===
|
|
||||||
"tablet"}
|
|
||||||
class:mobile-preview={$builderStore.previewDevice ===
|
|
||||||
"mobile"}
|
|
||||||
>
|
|
||||||
<!-- Actual app -->
|
|
||||||
<div id="app-root">
|
|
||||||
{#if showDevTools}
|
|
||||||
<DevToolsHeader />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
<div id="app-body">
|
<!-- Clip boundary for selection indicators -->
|
||||||
{#if permissionError}
|
<div
|
||||||
<div class="error">
|
id="clip-root"
|
||||||
<Layout justifyItems="center" gap="S">
|
class:preview={$builderStore.inBuilder}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
class:tablet-preview={$builderStore.previewDevice ===
|
||||||
{@html ErrorSVG}
|
"tablet"}
|
||||||
<Heading size="L">
|
class:mobile-preview={$builderStore.previewDevice ===
|
||||||
You don't have permission to use this app
|
"mobile"}
|
||||||
</Heading>
|
>
|
||||||
<Body size="S">
|
<!-- Actual app -->
|
||||||
Ask your administrator to grant you access
|
<div id="app-root">
|
||||||
</Body>
|
{#if showDevTools}
|
||||||
</Layout>
|
<DevToolsHeader />
|
||||||
</div>
|
{/if}
|
||||||
{:else if !$screenStore.activeLayout}
|
|
||||||
<div class="error">
|
|
||||||
<Layout justifyItems="center" gap="S">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html ErrorSVG}
|
|
||||||
<Heading size="L">
|
|
||||||
Something went wrong rendering your app
|
|
||||||
</Heading>
|
|
||||||
<Body size="S">
|
|
||||||
Get in touch with support if this issue persists
|
|
||||||
</Body>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
{:else if embedNoScreens}
|
|
||||||
<div class="error">
|
|
||||||
<Layout justifyItems="center" gap="S">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html ErrorSVG}
|
|
||||||
<Heading size="L">
|
|
||||||
This Budibase app is not publicly accessible
|
|
||||||
</Heading>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<CustomThemeWrapper>
|
|
||||||
{#key $screenStore.activeLayout._id}
|
|
||||||
<Component
|
|
||||||
isLayout
|
|
||||||
instance={$screenStore.activeLayout.props}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
<!--
|
<div id="app-body">
|
||||||
|
{#if permissionError}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
You don't have permission to use this app
|
||||||
|
</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Ask your administrator to grant you access
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else if !$screenStore.activeLayout}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
Something went wrong rendering your app
|
||||||
|
</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Get in touch with support if this issue persists
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else if embedNoScreens}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
This Budibase app is not publicly accessible
|
||||||
|
</Heading>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CustomThemeWrapper>
|
||||||
|
{#key $screenStore.activeLayout._id}
|
||||||
|
<Component
|
||||||
|
isLayout
|
||||||
|
instance={$screenStore.activeLayout.props}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
<!--
|
||||||
Flatpickr needs to be inside the theme wrapper.
|
Flatpickr needs to be inside the theme wrapper.
|
||||||
It also needs its own container because otherwise it hijacks
|
It also needs its own container because otherwise it hijacks
|
||||||
key events on the whole page. It is painful to work with.
|
key events on the whole page. It is painful to work with.
|
||||||
-->
|
-->
|
||||||
<div id="flatpickr-root" />
|
<div id="flatpickr-root" />
|
||||||
|
|
||||||
<!-- Modal container to ensure they sit on top -->
|
<!-- Modal container to ensure they sit on top -->
|
||||||
<div class="modal-container" />
|
<div class="modal-container" />
|
||||||
|
|
||||||
<!-- Layers on top of app -->
|
<!-- Layers on top of app -->
|
||||||
<NotificationDisplay />
|
<NotificationDisplay />
|
||||||
<ConfirmationDisplay />
|
<ConfirmationDisplay />
|
||||||
<PeekScreenDisplay />
|
<PeekScreenDisplay />
|
||||||
</CustomThemeWrapper>
|
</CustomThemeWrapper>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDevTools}
|
{#if showDevTools}
|
||||||
<DevTools />
|
<DevTools />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
||||||
|
<FreeFooter />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
<!-- Preview and dev tools utilities -->
|
||||||
<FreeFooter />
|
{#if $appStore.isDevApp}
|
||||||
|
<SelectionIndicator />
|
||||||
|
{/if}
|
||||||
|
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
||||||
|
<HoverIndicator />
|
||||||
|
{/if}
|
||||||
|
{#if $builderStore.inBuilder}
|
||||||
|
<DNDHandler />
|
||||||
|
<GridDNDHandler />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</SnippetsProvider>
|
||||||
<!-- Preview and dev tools utilities -->
|
</QueryParamsProvider>
|
||||||
{#if $appStore.isDevApp}
|
</RowSelectionProvider>
|
||||||
<SelectionIndicator />
|
</StateBindingsProvider>
|
||||||
{/if}
|
</UserBindingsProvider>
|
||||||
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
</DeviceBindingsProvider>
|
||||||
<HoverIndicator />
|
{/if}
|
||||||
{/if}
|
|
||||||
{#if $builderStore.inBuilder}
|
|
||||||
<DNDHandler />
|
|
||||||
<GridDNDHandler />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</SnippetsProvider>
|
|
||||||
</QueryParamsProvider>
|
|
||||||
</RowSelectionProvider>
|
|
||||||
</StateBindingsProvider>
|
|
||||||
</UserBindingsProvider>
|
|
||||||
</DeviceBindingsProvider>
|
|
||||||
</div>
|
</div>
|
||||||
<KeyboardManager />
|
<KeyboardManager />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
@ -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",
|
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||||
key: "name",
|
sourceType: TableSourceType.INTERNAL,
|
||||||
schema: {
|
schema: {
|
||||||
Price: {
|
Price: {
|
||||||
type: "number",
|
name: "Price",
|
||||||
constraints: {},
|
type: FieldType.NUMBER,
|
||||||
},
|
},
|
||||||
Category: {
|
Category: {
|
||||||
|
name: "Category",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
type: "string",
|
type: "string",
|
||||||
constraints: {
|
|
||||||
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,52 +105,41 @@ 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",
|
TestView: expect.objectContaining({
|
||||||
calculation: "stats",
|
field: "Price",
|
||||||
tableId: table._id,
|
calculation: "stats",
|
||||||
|
tableId: table._id,
|
||||||
|
filters: [],
|
||||||
|
schema: {
|
||||||
|
sum: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
sumsqr: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
avg: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.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({
|
|
||||||
field: "Price",
|
|
||||||
calculation: "stats",
|
|
||||||
tableId: table._id,
|
|
||||||
filters: [],
|
|
||||||
schema: {
|
|
||||||
sum: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
min: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
max: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
sumsqr: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
avg: {
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
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)
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,3 +2,7 @@ export enum ServiceType {
|
||||||
WORKER = "worker",
|
WORKER = "worker",
|
||||||
APPS = "apps",
|
APPS = "apps",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum MaintenanceType {
|
||||||
|
SQS_MISSING = "sqs_missing",
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue