1
0
Fork 0
mirror of synced 2024-08-23 05:51:29 +12:00

Merge branch 'master' into fix/single-user-support-automations

This commit is contained in:
deanhannigan 2024-05-24 11:09:47 +01:00 committed by GitHub
commit bbeb6e88e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1448 additions and 351 deletions

View file

@ -61,7 +61,7 @@ http {
set $csp_img "img-src http: https: data: blob:"; set $csp_img "img-src http: https: data: blob:";
set $csp_manifest "manifest-src 'self'"; set $csp_manifest "manifest-src 'self'";
set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live"; set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live";
set $csp_worker "worker-src 'none'"; set $csp_worker "worker-src blob:";
error_page 502 503 504 /error.html; error_page 502 503 504 /error.html;
location = /error.html { location = /error.html {

View file

@ -1,5 +1,5 @@
{ {
"version": "2.26.4", "version": "2.27.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",
@ -22,4 +22,4 @@
"loadEnvFiles": false "loadEnvFiles": false
} }
} }
} }

View file

@ -18,6 +18,14 @@ export const generateAppID = (tenantId?: string | null) => {
return `${id}${newid()}` return `${id}${newid()}`
} }
/**
* Generates a new table ID.
* @returns The new table ID which the table doc can be stored under.
*/
export function generateTableID() {
return `${DocumentType.TABLE}${SEPARATOR}${newid()}`
}
/** /**
* Gets a new row ID for the specified table. * Gets a new row ID for the specified table.
* @param tableId The table which the row is being created for. * @param tableId The table which the row is being created for.

View file

@ -4,13 +4,14 @@
export let max export let max
export let hideArrows = false export let hideArrows = false
export let width export let width
export let type = "number"
$: style = width ? `width:${width}px;` : "" $: style = width ? `width:${width}px;` : ""
</script> </script>
<input <input
class:hide-arrows={hideArrows} class:hide-arrows={hideArrows}
type="number" {type}
{style} {style}
{value} {value}
{min} {min}
@ -51,4 +52,7 @@
input.hide-arrows { input.hide-arrows {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
</style> </style>

View file

@ -1,5 +1,4 @@
<script> <script>
import { cleanInput } from "./utils"
import dayjs from "dayjs" import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte" import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
@ -8,39 +7,26 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: displayValue = value || dayjs() $: displayValue = value?.format("HH:mm")
const handleHourChange = e => { const handleChange = e => {
dispatch("change", displayValue.hour(parseInt(e.target.value))) if (!e.target.value) {
dispatch("change", undefined)
return
}
const [hour, minute] = e.target.value.split(":").map(x => parseInt(x))
dispatch("change", (value || dayjs()).hour(hour).minute(minute))
} }
const handleMinuteChange = e => {
dispatch("change", displayValue.minute(parseInt(e.target.value)))
}
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
const cleanMinute = cleanInput({ max: 59, pad: 2, fallback: "00" })
</script> </script>
<div class="time-picker"> <div class="time-picker">
<NumberInput <NumberInput
hideArrows hideArrows
value={displayValue.hour().toString().padStart(2, "0")} type={"time"}
min={0} value={displayValue}
max={23} on:input={handleChange}
width={20} on:change={handleChange}
on:input={cleanHour}
on:change={handleHourChange}
/>
<span>:</span>
<NumberInput
hideArrows
value={displayValue.minute().toString().padStart(2, "0")}
min={0}
max={59}
width={20}
on:input={cleanMinute}
on:change={handleMinuteChange}
/> />
</div> </div>
@ -50,10 +36,4 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
} }
.time-picker span {
font-weight: bold;
font-size: 18px;
z-index: 0;
margin-bottom: 1px;
}
</style> </style>

View file

@ -166,9 +166,14 @@ export const stringifyDate = (
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) { if (offsetForTimezone) {
// Ensure we use the correct offset for the date // Ensure we use the correct offset for the date
const referenceDate = timeOnly ? new Date() : value.toDate() const referenceDate = value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000 const offset = referenceDate.getTimezoneOffset() * 60000
return new Date(value.valueOf() - offset).toISOString().slice(0, -1) const date = new Date(value.valueOf() - offset)
if (timeOnly) {
// Extract HH:mm
return date.toISOString().slice(11, 16)
}
return date.toISOString().slice(0, -1)
} }
// For date-only fields, construct a manual timestamp string without a time // For date-only fields, construct a manual timestamp string without a time
@ -177,7 +182,7 @@ export const stringifyDate = (
const year = value.year() const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0") const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0") const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000` return `${year}-${month}-${day}`
} }
// Otherwise use a normal ISO string with time and timezone // Otherwise use a normal ISO string with time and timezone

View file

@ -586,13 +586,17 @@
bind:constraints={editableColumn.constraints} bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors} bind:optionColors={editableColumn.optionColors}
/> />
{:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn} {:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">
<Label size="M">Earliest</Label> <Label size="M">Earliest</Label>
</div> </div>
<div class="input-length"> <div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.earliest} /> <DatePicker
bind:value={editableColumn.constraints.datetime.earliest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div> </div>
</div> </div>
@ -601,30 +605,36 @@
<Label size="M">Latest</Label> <Label size="M">Latest</Label>
</div> </div>
<div class="input-length"> <div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.latest} /> <DatePicker
</div> bind:value={editableColumn.constraints.datetime.latest}
</div> enableTime={!editableColumn.dateOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly} timeOnly={editableColumn.timeOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/> />
</div> </div>
</div>
{#if !editableColumn.timeOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/>
</div>
{/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{/if} {/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn} {:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">

View file

@ -1,5 +1,5 @@
<script> <script>
import { AbsTooltip, Icon } from "@budibase/bbui" import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
@ -114,9 +114,14 @@
</div> </div>
{:else if icon} {:else if icon}
<div class="icon" class:right={rightAlignIcon}> <div class="icon" class:right={rightAlignIcon}>
<AbsTooltip type="info" position="right" text={iconTooltip}> <Icon
<Icon color={iconColor} size="S" name={icon} /> color={iconColor}
</AbsTooltip> size="S"
name={icon}
tooltip={iconTooltip}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Right}
/>
</div> </div>
{/if} {/if}
<div class="text" title={showTooltip ? text : null}> <div class="text" title={showTooltip ? text : null}>

View file

@ -237,7 +237,12 @@
const onChangeJSValue = e => { const onChangeJSValue = e => {
jsValue = encodeJSBinding(e.detail) jsValue = encodeJSBinding(e.detail)
updateValue(jsValue) if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(jsValue)
}
} }
onMount(() => { onMount(() => {

View file

@ -4,7 +4,6 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "dataBinding" } from "dataBinding"
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"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"

View file

@ -1,5 +1,5 @@
<script> <script>
import { screenStore, componentStore } from "stores/builder" import { screenStore, componentStore, navigationStore } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
ActionMenu, ActionMenu,
@ -12,6 +12,7 @@
import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte" import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl" import sanitizeUrl from "helpers/sanitizeUrl"
import { makeComponentUnique } from "helpers/components" import { makeComponentUnique } from "helpers/components"
import { capitalise } from "helpers"
export let screenId export let screenId
@ -48,6 +49,13 @@
try { try {
// Create the screen // Create the screen
await screenStore.save(duplicateScreen) await screenStore.save(duplicateScreen)
// Add new screen to navigation
await navigationStore.saveLink(
duplicateScreen.routing.route,
capitalise(duplicateScreen.routing.route.split("/")[1]),
duplicateScreen.routing.roleId
)
} catch (error) { } catch (error) {
notifications.error("Error duplicating screen") notifications.error("Error duplicating screen")
} }

View file

@ -20,7 +20,7 @@ import {
previewStore, previewStore,
tables, tables,
componentTreeNodesStore, componentTreeNodesStore,
} from "stores/builder/index" } from "stores/builder"
import { buildFormSchema, getSchemaForDatasource } from "dataBinding" import { buildFormSchema, getSchemaForDatasource } from "dataBinding"
import { import {
BUDIBASE_INTERNAL_DB_ID, BUDIBASE_INTERNAL_DB_ID,
@ -30,6 +30,7 @@ import {
} from "constants/backend" } from "constants/backend"
import BudiStore from "../BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { FieldType } from "@budibase/types"
export const INITIAL_COMPONENTS_STATE = { export const INITIAL_COMPONENTS_STATE = {
components: {}, components: {},
@ -296,6 +297,80 @@ export class ComponentStore extends BudiStore {
} }
} }
}) })
// Add default bindings to card blocks
if (component._component.endsWith("/cardsblock")) {
// Only proceed if the card is empty, i.e. we just changed datasource or
// just created the card
const cardKeys = ["cardTitle", "cardSubtitle", "cardDescription"]
if (cardKeys.every(key => !component[key]) && !component.cardImageURL) {
const { _id, dataSource } = component
if (dataSource) {
const { schema, table } = getSchemaForDatasource(screen, dataSource)
// Finds fields by types from the schema of the configured datasource
const findFieldTypes = fieldTypes => {
if (!Array.isArray(fieldTypes)) {
fieldTypes = [fieldTypes]
}
return Object.entries(schema || {})
.filter(([name, fieldSchema]) => {
return (
fieldTypes.includes(fieldSchema.type) &&
!fieldSchema.autoColumn &&
name !== table?.primaryDisplay &&
!name.startsWith("_")
)
})
.map(([name]) => name)
}
// Inserts a card binding for a certain setting
const addBinding = (key, fallback, ...parts) => {
if (parts.some(x => x == null)) {
component[key] = fallback
} else {
parts.unshift(`${_id}-repeater`)
component[key] = `{{ ${parts.map(safe).join(".")} }}`
}
}
// Extract good field candidates to prefill our cards with.
// Use the primary display as the best field, if it exists.
const shortFields = [
...findFieldTypes(FieldType.STRING),
...findFieldTypes(FieldType.OPTIONS),
...findFieldTypes(FieldType.ARRAY),
...findFieldTypes(FieldType.NUMBER),
]
const longFields = findFieldTypes(FieldType.LONGFORM)
if (schema?.[table?.primaryDisplay]) {
shortFields.unshift(table.primaryDisplay)
}
// Fill title and subtitle with short fields
addBinding("cardTitle", "Title", shortFields[0])
addBinding("cardSubtitle", "Subtitle", shortFields[1])
// Fill description with a long field if possible
const longField = longFields[0] ?? shortFields[2]
addBinding("cardDescription", "Description", longField)
// Attempt to fill the image setting.
// Check single attachment fields first.
let imgField = findFieldTypes(FieldType.ATTACHMENT_SINGLE)[0]
if (imgField) {
addBinding("cardImageURL", null, imgField, "url")
} else {
// Then try multi-attachment fields if no single ones exist
imgField = findFieldTypes(FieldType.ATTACHMENTS)[0]
if (imgField) {
addBinding("cardImageURL", null, imgField, 0, "url")
}
}
}
}
}
} }
/** /**
@ -324,21 +399,21 @@ export class ComponentStore extends BudiStore {
...presetProps, ...presetProps,
} }
// Enrich empty settings // Standard post processing
this.enrichEmptySettings(instance, { this.enrichEmptySettings(instance, {
parent, parent,
screen: get(selectedScreen), screen: get(selectedScreen),
useDefaultValues: true, useDefaultValues: true,
}) })
// Migrate nested component settings
this.migrateSettings(instance) this.migrateSettings(instance)
// Add any extra properties the component needs // Custom post processing for creation only
let extras = {} let extras = {}
if (definition.hasChildren) { if (definition.hasChildren) {
extras._children = [] extras._children = []
} }
// Add step name to form steps
if (componentName.endsWith("/formstep")) { if (componentName.endsWith("/formstep")) {
const parentForm = findClosestMatchingComponent( const parentForm = findClosestMatchingComponent(
get(selectedScreen).props, get(selectedScreen).props,
@ -351,6 +426,7 @@ export class ComponentStore extends BudiStore {
extras.step = formSteps.length + 1 extras.step = formSteps.length + 1
extras._instanceName = `Step ${formSteps.length + 1}` extras._instanceName = `Step ${formSteps.length + 1}`
} }
return { return {
...cloneDeep(instance), ...cloneDeep(instance),
...extras, ...extras,
@ -463,7 +539,6 @@ export class ComponentStore extends BudiStore {
if (!componentId || !screenId) { if (!componentId || !screenId) {
const state = get(this.store) const state = get(this.store)
componentId = componentId || state.selectedComponentId componentId = componentId || state.selectedComponentId
const screenState = get(screenStore) const screenState = get(screenStore)
screenId = screenId || screenState.selectedScreenId screenId = screenId || screenState.selectedScreenId
} }
@ -471,7 +546,6 @@ export class ComponentStore extends BudiStore {
return return
} }
const patchScreen = screen => { const patchScreen = screen => {
// findComponent looks in the tree not comp.settings[0]
let component = findComponent(screen.props, componentId) let component = findComponent(screen.props, componentId)
if (!component) { if (!component) {
return false return false
@ -480,7 +554,7 @@ export class ComponentStore extends BudiStore {
// Mutates the fetched component with updates // Mutates the fetched component with updates
const patchResult = patchFn(component, screen) const patchResult = patchFn(component, screen)
// Mutates the component with any required settings updates // Post processing
const migrated = this.migrateSettings(component) const migrated = this.migrateSettings(component)
// Returning an explicit false signifies that we should skip this // Returning an explicit false signifies that we should skip this

View file

@ -23,6 +23,7 @@ import {
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
DEFAULT_BB_DATASOURCE_ID, DEFAULT_BB_DATASOURCE_ID,
} from "constants/backend" } from "constants/backend"
import { makePropSafe as safe } from "@budibase/string-templates"
// Could move to fixtures // Could move to fixtures
const COMP_PREFIX = "@budibase/standard-components" const COMP_PREFIX = "@budibase/standard-components"
@ -360,8 +361,30 @@ describe("Component store", () => {
resourceId: internalTableDoc._id, resourceId: internalTableDoc._id,
type: "table", type: "table",
}) })
return comp
} }
it("enrichEmptySettings - initialise cards blocks with correct fields", async ctx => {
const comp = enrichSettingsDS("cardsblock", ctx)
const expectBinding = (setting, ...parts) => {
expect(comp[setting]).toStrictEqual(
`{{ ${safe(`${comp._id}-repeater`)}.${parts.map(safe).join(".")} }}`
)
}
expectBinding("cardTitle", internalTableDoc.schema.MediaTitle.name)
expectBinding("cardSubtitle", internalTableDoc.schema.MediaVersion.name)
expectBinding(
"cardDescription",
internalTableDoc.schema.MediaDescription.name
)
expectBinding(
"cardImageURL",
internalTableDoc.schema.MediaImage.name,
"url"
)
})
it("enrichEmptySettings - set default datasource for 'table' setting type", async ctx => { it("enrichEmptySettings - set default datasource for 'table' setting type", async ctx => {
enrichSettingsDS("formblock", ctx) enrichSettingsDS("formblock", ctx)
}) })

View file

@ -8,6 +8,7 @@ import {
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
DEFAULT_BB_DATASOURCE_ID, DEFAULT_BB_DATASOURCE_ID,
} from "constants/backend" } from "constants/backend"
import { FieldType } from "@budibase/types"
const getDocId = () => { const getDocId = () => {
return v4().replace(/-/g, "") return v4().replace(/-/g, "")
@ -45,6 +46,52 @@ export const COMPONENT_DEFINITIONS = {
}, },
], ],
}, },
cardsblock: {
block: true,
name: "Cards Block",
settings: [
{
type: "dataSource",
label: "Data",
key: "dataSource",
required: true,
},
{
section: true,
name: "Cards",
settings: [
{
type: "text",
key: "cardTitle",
label: "Title",
nested: true,
resetOn: "dataSource",
},
{
type: "text",
key: "cardSubtitle",
label: "Subtitle",
nested: true,
resetOn: "dataSource",
},
{
type: "text",
key: "cardDescription",
label: "Description",
nested: true,
resetOn: "dataSource",
},
{
type: "text",
key: "cardImageURL",
label: "Image URL",
nested: true,
resetOn: "dataSource",
},
],
},
],
},
container: { container: {
name: "Container", name: "Container",
}, },
@ -262,14 +309,23 @@ export const internalTableDoc = {
name: "Media", name: "Media",
sourceId: BUDIBASE_INTERNAL_DB_ID, sourceId: BUDIBASE_INTERNAL_DB_ID,
sourceType: DB_TYPE_INTERNAL, sourceType: DB_TYPE_INTERNAL,
primaryDisplay: "MediaTitle",
schema: { schema: {
MediaTitle: { MediaTitle: {
name: "MediaTitle", name: "MediaTitle",
type: "string", type: FieldType.STRING,
}, },
MediaVersion: { MediaVersion: {
name: "MediaVersion", name: "MediaVersion",
type: "string", type: FieldType.STRING,
},
MediaDescription: {
name: "MediaDescription",
type: FieldType.LONGFORM,
},
MediaImage: {
name: "MediaImage",
type: FieldType.ATTACHMENT_SINGLE,
}, },
}, },
} }

View file

@ -6243,27 +6243,28 @@
"key": "cardTitle", "key": "cardTitle",
"label": "Title", "label": "Title",
"nested": true, "nested": true,
"defaultValue": "Title" "resetOn": "dataSource"
}, },
{ {
"type": "text", "type": "text",
"key": "cardSubtitle", "key": "cardSubtitle",
"label": "Subtitle", "label": "Subtitle",
"nested": true, "nested": true,
"defaultValue": "Subtitle" "resetOn": "dataSource"
}, },
{ {
"type": "text", "type": "text",
"key": "cardDescription", "key": "cardDescription",
"label": "Description", "label": "Description",
"nested": true, "nested": true,
"defaultValue": "Description" "resetOn": "dataSource"
}, },
{ {
"type": "text", "type": "text",
"key": "cardImageURL", "key": "cardImageURL",
"label": "Image URL", "label": "Image URL",
"nested": true "nested": true,
"resetOn": "dataSource"
}, },
{ {
"type": "boolean", "type": "boolean",

View file

@ -1,5 +1,5 @@
<script> <script>
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher } from "svelte"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
@ -13,8 +13,6 @@
export let navStateStore export let navStateStore
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const sdk = getContext("sdk")
const { linkable } = sdk
let renderKey let renderKey
@ -46,10 +44,9 @@
styled styled
--> -->
<a <a
href={url} href="#{url}"
on:click={onClickLink} on:click={onClickLink}
use:active={url} use:active={url}
use:linkable
class:active={false} class:active={false}
> >
{text} {text}
@ -73,10 +70,9 @@
{#each subLinks || [] as subLink} {#each subLinks || [] as subLink}
{#if subLink.internalLink} {#if subLink.internalLink}
<a <a
href={subLink.url} href="#{subLink.url}"
on:click={onClickLink} on:click={onClickLink}
use:active={subLink.url} use:active={subLink.url}
use:linkable
> >
{subLink.text} {subLink.text}
</a> </a>

View file

@ -238,7 +238,13 @@ const triggerAutomationHandler = async action => {
} }
} }
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters let { url, peek, externalNewTab, type } = action.parameters
// Ensure in-app navigation starts with a slash
if (type === "screen" && url && !url.startsWith("/")) {
url = `/${url}`
}
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)
closeSidePanelHandler() closeSidePanelHandler()
} }

View file

@ -1,3 +1,4 @@
import dayjs from "dayjs"
import { import {
AutoFieldSubType, AutoFieldSubType,
AutoReason, AutoReason,
@ -285,65 +286,73 @@ export class ExternalRequest<T extends Operation> {
// parse floats/numbers // parse floats/numbers
if (field.type === FieldType.NUMBER && !isNaN(parseFloat(row[key]))) { if (field.type === FieldType.NUMBER && !isNaN(parseFloat(row[key]))) {
newRow[key] = parseFloat(row[key]) newRow[key] = parseFloat(row[key])
} } else if (field.type === FieldType.LINK) {
// if its not a link then just copy it over const { tableName: linkTableName } = breakExternalTableId(
if (field.type !== FieldType.LINK) { field?.tableId
newRow[key] = row[key] )
continue // table has to exist for many to many
} if (!linkTableName || !this.tables[linkTableName]) {
const { tableName: linkTableName } = breakExternalTableId(field?.tableId) continue
// table has to exist for many to many
if (!linkTableName || !this.tables[linkTableName]) {
continue
}
const linkTable = this.tables[linkTableName]
// @ts-ignore
const linkTablePrimary = linkTable.primary[0]
// one to many
if (isOneSide(field)) {
let id = row[key][0]
if (id) {
if (typeof row[key] === "string") {
id = decodeURIComponent(row[key]).match(/\[(.*?)\]/)?.[1]
}
newRow[field.foreignKey || linkTablePrimary] = breakRowIdField(id)[0]
} else {
// Removing from both new and row, as we don't know if it has already been processed
row[field.foreignKey || linkTablePrimary] = null
newRow[field.foreignKey || linkTablePrimary] = null
} }
} const linkTable = this.tables[linkTableName]
// many to many
else if (isManyToMany(field)) {
// we're not inserting a doc, will be a bunch of update calls
const otherKey: string = field.throughFrom || linkTablePrimary
const thisKey: string = field.throughTo || tablePrimary
for (const relationship of row[key]) {
manyRelationships.push({
tableId: field.through || field.tableId,
isUpdate: false,
key: otherKey,
[otherKey]: breakRowIdField(relationship)[0],
// leave the ID for enrichment later
[thisKey]: `{{ literal ${tablePrimary} }}`,
})
}
}
// many to one
else {
const thisKey: string = "id"
// @ts-ignore // @ts-ignore
const otherKey: string = field.fieldName const linkTablePrimary = linkTable.primary[0]
for (const relationship of row[key]) { // one to many
manyRelationships.push({ if (isOneSide(field)) {
tableId: field.tableId, let id = row[key][0]
isUpdate: true, if (id) {
key: otherKey, if (typeof row[key] === "string") {
[thisKey]: breakRowIdField(relationship)[0], id = decodeURIComponent(row[key]).match(/\[(.*?)\]/)?.[1]
// leave the ID for enrichment later }
[otherKey]: `{{ literal ${tablePrimary} }}`, newRow[field.foreignKey || linkTablePrimary] =
}) breakRowIdField(id)[0]
} else {
// Removing from both new and row, as we don't know if it has already been processed
row[field.foreignKey || linkTablePrimary] = null
newRow[field.foreignKey || linkTablePrimary] = null
}
} }
// many to many
else if (isManyToMany(field)) {
// we're not inserting a doc, will be a bunch of update calls
const otherKey: string = field.throughFrom || linkTablePrimary
const thisKey: string = field.throughTo || tablePrimary
for (const relationship of row[key]) {
manyRelationships.push({
tableId: field.through || field.tableId,
isUpdate: false,
key: otherKey,
[otherKey]: breakRowIdField(relationship)[0],
// leave the ID for enrichment later
[thisKey]: `{{ literal ${tablePrimary} }}`,
})
}
}
// many to one
else {
const thisKey: string = "id"
// @ts-ignore
const otherKey: string = field.fieldName
for (const relationship of row[key]) {
manyRelationships.push({
tableId: field.tableId,
isUpdate: true,
key: otherKey,
[thisKey]: breakRowIdField(relationship)[0],
// leave the ID for enrichment later
[otherKey]: `{{ literal ${tablePrimary} }}`,
})
}
}
} else if (
field.type === FieldType.DATETIME &&
field.timeOnly &&
row[key] &&
dayjs(row[key]).isValid()
) {
newRow[key] = dayjs(row[key]).format("HH:mm")
} else {
newRow[key] = row[key]
} }
} }
// we return the relationships that may need to be created in the through table // we return the relationships that may need to be created in the through table

View file

@ -57,5 +57,5 @@ export function isFormat(format: any): format is Format {
} }
export function parseCsvExport<T>(value: string) { export function parseCsvExport<T>(value: string) {
return JSON.parse(value?.replace(/'/g, '"')) as T return JSON.parse(value) as T
} }

View file

@ -485,6 +485,25 @@ describe.each([
) )
expect(response.message).toBe("Cannot create new user entry.") expect(response.message).toBe("Cannot create new user entry.")
}) })
it("should not mis-parse date string out of JSON", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
type: FieldType.STRING,
name: "name",
},
},
})
)
const row = await config.api.row.save(table._id!, {
name: `{ "foo": "2023-01-26T11:48:57.000Z" }`,
})
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
})
}) })
describe("get", () => { describe("get", () => {

View file

@ -1,6 +1,6 @@
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 { db as dbCore, utils } from "@budibase/backend-core"
import * as setup from "./utilities" import * as setup from "./utilities"
import { import {
@ -17,6 +17,7 @@ import {
TableSchema, TableSchema,
User, User,
Row, Row,
RelationshipType,
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper" import tk from "timekeeper"
@ -73,31 +74,81 @@ describe.each([
}) })
async function createTable(schema: TableSchema) { async function createTable(schema: TableSchema) {
table = await config.api.table.save( return await config.api.table.save(
tableForDatasource(datasource, { schema }) tableForDatasource(datasource, { schema })
) )
} }
async function createRows(rows: Record<string, any>[]) { async function createRows(rows: Record<string, any>[]) {
await config.api.row.bulkImport(table._id!, { rows }) // Shuffling to avoid false positives given a fixed order
await config.api.row.bulkImport(table._id!, { rows: _.shuffle(rows) })
} }
class SearchAssertion { class SearchAssertion {
constructor(private readonly query: RowSearchParams) {} constructor(private readonly query: RowSearchParams) {}
private findRow(expectedRow: any, foundRows: any[]) { // We originally used _.isMatch to compare rows, but found that when
const row = foundRows.find(foundRow => _.isMatch(foundRow, expectedRow)) // comparing arrays it would return true if the source array was a subset of
// the target array. This would sometimes create false matches. This
// function is a more strict version of _.isMatch that only returns true if
// the source array is an exact match of the target.
//
// _.isMatch("100", "1") also returns true which is not what we want.
private isMatch<T extends Record<string, any>>(expected: T, found: T) {
if (!expected) {
throw new Error("Expected is undefined")
}
if (!found) {
return false
}
for (const key of Object.keys(expected)) {
if (Array.isArray(expected[key])) {
if (!Array.isArray(found[key])) {
return false
}
if (expected[key].length !== found[key].length) {
return false
}
if (!_.isMatch(found[key], expected[key])) {
return false
}
} else if (typeof expected[key] === "object") {
if (!this.isMatch(expected[key], found[key])) {
return false
}
} else {
if (expected[key] !== found[key]) {
return false
}
}
}
return true
}
// This function exists to ensure that the same row is not matched twice.
// When a row gets matched, we make sure to remove it from the list of rows
// we're matching against.
private popRow<T extends { [key: string]: any }>(
expectedRow: T,
foundRows: T[]
): NonNullable<T> {
const row = foundRows.find(row => this.isMatch(expectedRow, row))
if (!row) { if (!row) {
const fields = Object.keys(expectedRow) const fields = Object.keys(expectedRow)
// To make the error message more readable, we only include the fields // To make the error message more readable, we only include the fields
// that are present in the expected row. // that are present in the expected row.
const searchedObjects = foundRows.map(row => _.pick(row, fields)) const searchedObjects = foundRows.map(row => _.pick(row, fields))
throw new Error( throw new Error(
`Failed to find row: ${JSON.stringify( `Failed to find row:\n\n${JSON.stringify(
expectedRow expectedRow,
)} in ${JSON.stringify(searchedObjects)}` null,
2
)}\n\nin\n\n${JSON.stringify(searchedObjects, null, 2)}`
) )
} }
foundRows.splice(foundRows.indexOf(row), 1)
return row return row
} }
@ -114,9 +165,9 @@ describe.each([
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toHaveLength(expectedRows.length) expect(foundRows).toHaveLength(expectedRows.length)
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toEqual( expect([...foundRows]).toEqual(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining(this.findRow(expectedRow, foundRows)) expect.objectContaining(this.popRow(expectedRow, foundRows))
) )
) )
} }
@ -133,10 +184,10 @@ describe.each([
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toHaveLength(expectedRows.length) expect(foundRows).toHaveLength(expectedRows.length)
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toEqual( expect([...foundRows]).toEqual(
expect.arrayContaining( expect.arrayContaining(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining(this.findRow(expectedRow, foundRows)) expect.objectContaining(this.popRow(expectedRow, foundRows))
) )
) )
) )
@ -152,10 +203,10 @@ describe.each([
}) })
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toEqual( expect([...foundRows]).toEqual(
expect.arrayContaining( expect.arrayContaining(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining(this.findRow(expectedRow, foundRows)) expect.objectContaining(this.popRow(expectedRow, foundRows))
) )
) )
) )
@ -186,7 +237,7 @@ describe.each([
describe("boolean", () => { describe("boolean", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
isTrue: { name: "isTrue", type: FieldType.BOOLEAN }, isTrue: { name: "isTrue", type: FieldType.BOOLEAN },
}) })
await createRows([{ isTrue: true }, { isTrue: false }]) await createRows([{ isTrue: true }, { isTrue: false }])
@ -316,7 +367,7 @@ describe.each([
}) })
) )
await createTable({ table = await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
appointment: { name: "appointment", type: FieldType.DATETIME }, appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: { single_user: {
@ -592,7 +643,7 @@ describe.each([
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => { describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
}) })
await createRows([{ name: "foo" }, { name: "bar" }]) await createRows([{ name: "foo" }, { name: "bar" }])
@ -712,6 +763,20 @@ describe.each([
expectQuery({ expectQuery({
range: { name: { low: "g", high: "h" } }, range: { name: { low: "g", high: "h" } },
}).toFindNothing()) }).toFindNothing())
!isLucene &&
it("ignores low if it's an empty object", () =>
expectQuery({
// @ts-ignore
range: { name: { low: {}, high: "z" } },
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
!isLucene &&
it("ignores high if it's an empty object", () =>
expectQuery({
// @ts-ignore
range: { name: { low: "a", high: {} } },
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
}) })
describe("empty", () => { describe("empty", () => {
@ -776,7 +841,7 @@ describe.each([
describe("numbers", () => { describe("numbers", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
age: { name: "age", type: FieldType.NUMBER }, age: { name: "age", type: FieldType.NUMBER },
}) })
await createRows([{ age: 1 }, { age: 10 }]) await createRows([{ age: 1 }, { age: 10 }])
@ -885,7 +950,7 @@ describe.each([
const JAN_10TH = "2020-01-10T00:00:00.000Z" const JAN_10TH = "2020-01-10T00:00:00.000Z"
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
dob: { name: "dob", type: FieldType.DATETIME }, dob: { name: "dob", type: FieldType.DATETIME },
}) })
@ -995,9 +1060,164 @@ describe.each([
}) })
}) })
!isInternal &&
describe("datetime - time only", () => {
const T_1000 = "10:00"
const T_1045 = "10:45"
const T_1200 = "12:00"
const T_1530 = "15:30"
const T_0000 = "00:00"
const UNEXISTING_TIME = "10:01"
const NULL_TIME__ID = `null_time__id`
beforeAll(async () => {
table = await createTable({
timeid: { name: "timeid", type: FieldType.STRING },
time: { name: "time", type: FieldType.DATETIME, timeOnly: true },
})
await createRows([
{ timeid: NULL_TIME__ID, time: null },
{ time: T_1000 },
{ time: T_1045 },
{ time: T_1200 },
{ time: T_1530 },
{ time: T_0000 },
])
})
describe("equal", () => {
it("successfully finds a row", () =>
expectQuery({ equal: { time: T_1000 } }).toContainExactly([
{ time: "10:00:00" },
]))
it("fails to find nonexistent row", () =>
expectQuery({ equal: { time: UNEXISTING_TIME } }).toFindNothing())
})
describe("notEqual", () => {
it("successfully finds a row", () =>
expectQuery({ notEqual: { time: T_1000 } }).toContainExactly([
{ timeid: NULL_TIME__ID },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
{ time: "00:00:00" },
]))
it("return all when requesting non-existing", () =>
expectQuery({ notEqual: { time: UNEXISTING_TIME } }).toContainExactly(
[
{ timeid: NULL_TIME__ID },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
{ time: "00:00:00" },
]
))
})
describe("oneOf", () => {
it("successfully finds a row", () =>
expectQuery({ oneOf: { time: [T_1000] } }).toContainExactly([
{ time: "10:00:00" },
]))
it("fails to find nonexistent row", () =>
expectQuery({ oneOf: { time: [UNEXISTING_TIME] } }).toFindNothing())
})
describe("range", () => {
it("successfully finds a row", () =>
expectQuery({
range: { time: { low: T_1045, high: T_1045 } },
}).toContainExactly([{ time: "10:45:00" }]))
it("successfully finds multiple rows", () =>
expectQuery({
range: { time: { low: T_1045, high: T_1530 } },
}).toContainExactly([
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
]))
it("successfully finds no rows", () =>
expectQuery({
range: { time: { low: UNEXISTING_TIME, high: UNEXISTING_TIME } },
}).toFindNothing())
})
describe("sort", () => {
it("sorts ascending", () =>
expectSearch({
query: {},
sort: "time",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ timeid: NULL_TIME__ID },
{ time: "00:00:00" },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
]))
it("sorts descending", () =>
expectSearch({
query: {},
sort: "time",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ time: "15:30:00" },
{ time: "12:00:00" },
{ time: "10:45:00" },
{ time: "10:00:00" },
{ time: "00:00:00" },
{ timeid: NULL_TIME__ID },
]))
describe("sortType STRING", () => {
it("sorts ascending", () =>
expectSearch({
query: {},
sort: "time",
sortType: SortType.STRING,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ timeid: NULL_TIME__ID },
{ time: "00:00:00" },
{ time: "10:00:00" },
{ time: "10:45:00" },
{ time: "12:00:00" },
{ time: "15:30:00" },
]))
it("sorts descending", () =>
expectSearch({
query: {},
sort: "time",
sortType: SortType.STRING,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ time: "15:30:00" },
{ time: "12:00:00" },
{ time: "10:45:00" },
{ time: "10:00:00" },
{ time: "00:00:00" },
{ timeid: NULL_TIME__ID },
]))
})
})
})
describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => { describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
numbers: { numbers: {
name: "numbers", name: "numbers",
type: FieldType.ARRAY, type: FieldType.ARRAY,
@ -1077,7 +1297,7 @@ describe.each([
const BIG = "9223372036854775807" const BIG = "9223372036854775807"
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
num: { name: "num", type: FieldType.BIGINT }, num: { name: "num", type: FieldType.BIGINT },
}) })
await createRows([{ num: SMALL }, { num: MEDIUM }, { num: BIG }]) await createRows([{ num: SMALL }, { num: MEDIUM }, { num: BIG }])
@ -1168,7 +1388,7 @@ describe.each([
isInternal && isInternal &&
describe("auto", () => { describe("auto", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
auto: { auto: {
name: "auto", name: "auto",
type: FieldType.AUTO, type: FieldType.AUTO,
@ -1295,6 +1515,25 @@ describe.each([
{ auto: 2 }, { auto: 2 },
{ auto: 1 }, { auto: 1 },
])) ]))
// This is important for pagination. The order of results must always
// be stable or pagination will break. We don't want the user to need
// to specify an order for pagination to work.
it("is stable without a sort specified", async () => {
let { rows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query: {},
})
for (let i = 0; i < 10; i++) {
const response = await config.api.row.search(table._id!, {
tableId: table._id!,
limit: 1,
query: {},
})
expect(response.rows).toEqual(rows)
}
})
}) })
// TODO(samwho): fix for SQS // TODO(samwho): fix for SQS
@ -1333,20 +1572,222 @@ describe.each([
describe("field name 1:name", () => { describe("field name 1:name", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
"1:name": { name: "1:name", type: FieldType.STRING }, "1:name": { name: "1:name", type: FieldType.STRING },
}) })
await createRows([{ "1:name": "bar" }, { "1:name": "foo" }]) await createRows([{ "1:name": "bar" }, { "1:name": "foo" }])
}) })
it("successfully finds a row", () =>
expectQuery({ equal: { "1:1:name": "bar" } }).toContainExactly([
{ "1:name": "bar" },
]))
it("fails to find nonexistent row", () =>
expectQuery({ equal: { "1:1:name": "none" } }).toFindNothing())
})
describe("user", () => {
let user1: User
let user2: User
beforeAll(async () => {
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
table = await createTable({
user: {
name: "user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
})
await createRows([
{ user: JSON.stringify(user1) },
{ user: JSON.stringify(user2) },
{ user: null },
])
})
describe("equal", () => { describe("equal", () => {
it("successfully finds a row", () => it("successfully finds a row", () =>
expectQuery({ equal: { "1:1:name": "bar" } }).toContainExactly([ expectQuery({ equal: { user: user1._id } }).toContainExactly([
{ "1:name": "bar" }, { user: { _id: user1._id } },
])) ]))
it("fails to find nonexistent row", () => it("fails to find nonexistent row", () =>
expectQuery({ equal: { "1:1:name": "none" } }).toFindNothing()) expectQuery({ equal: { user: "us_none" } }).toFindNothing())
})
describe("notEqual", () => {
it("successfully finds a row", () =>
expectQuery({ notEqual: { user: user1._id } }).toContainExactly([
{ user: { _id: user2._id } },
{},
]))
it("fails to find nonexistent row", () =>
expectQuery({ notEqual: { user: "us_none" } }).toContainExactly([
{ user: { _id: user1._id } },
{ user: { _id: user2._id } },
{},
]))
})
describe("oneOf", () => {
it("successfully finds a row", () =>
expectQuery({ oneOf: { user: [user1._id] } }).toContainExactly([
{ user: { _id: user1._id } },
]))
it("fails to find nonexistent row", () =>
expectQuery({ oneOf: { user: ["us_none"] } }).toFindNothing())
})
describe("empty", () => {
it("finds empty rows", () =>
expectQuery({ empty: { user: null } }).toContainExactly([{}]))
})
describe("notEmpty", () => {
it("finds non-empty rows", () =>
expectQuery({ notEmpty: { user: null } }).toContainExactly([
{ user: { _id: user1._id } },
{ user: { _id: user2._id } },
]))
}) })
}) })
describe("multi user", () => {
let user1: User
let user2: User
beforeAll(async () => {
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
table = await createTable({
users: {
name: "users",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: { type: "array" },
},
number: {
name: "number",
type: FieldType.NUMBER,
},
})
await createRows([
{ number: 1, users: JSON.stringify([user1]) },
{ number: 2, users: JSON.stringify([user2]) },
{ number: 3, users: JSON.stringify([user1, user2]) },
{ number: 4, users: JSON.stringify([]) },
])
})
describe("contains", () => {
it("successfully finds a row", () =>
expectQuery({ contains: { users: [user1._id] } }).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
]))
it("fails to find nonexistent row", () =>
expectQuery({ contains: { users: ["us_none"] } }).toFindNothing())
})
describe("notContains", () => {
it("successfully finds a row", () =>
expectQuery({ notContains: { users: [user1._id] } }).toContainExactly([
{ users: [{ _id: user2._id }] },
{},
]))
it("fails to find nonexistent row", () =>
expectQuery({ notContains: { users: ["us_none"] } }).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user2._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
{},
]))
})
describe("containsAny", () => {
it("successfully finds rows", () =>
expectQuery({
containsAny: { users: [user1._id, user2._id] },
}).toContainExactly([
{ users: [{ _id: user1._id }] },
{ users: [{ _id: user2._id }] },
{ users: [{ _id: user1._id }, { _id: user2._id }] },
]))
it("fails to find nonexistent row", () =>
expectQuery({ containsAny: { users: ["us_none"] } }).toFindNothing())
})
describe("multi-column equals", () => {
it("successfully finds a row", () =>
expectQuery({
equal: { number: 1 },
contains: { users: [user1._id] },
}).toContainExactly([{ users: [{ _id: user1._id }], number: 1 }]))
it("fails to find nonexistent row", () =>
expectQuery({
equal: { number: 2 },
contains: { users: [user1._id] },
}).toFindNothing())
})
})
// This will never work for Lucene.
!isLucene &&
describe("relations", () => {
let otherTable: Table
let rows: Row[]
beforeAll(async () => {
otherTable = await createTable({
one: { name: "one", type: FieldType.STRING },
})
table = await createTable({
two: { name: "two", type: FieldType.STRING },
other: {
type: FieldType.LINK,
relationshipType: RelationshipType.ONE_TO_MANY,
name: "other",
fieldName: "other",
tableId: otherTable._id!,
constraints: {
type: "array",
},
},
})
rows = await Promise.all([
config.api.row.save(otherTable._id!, { one: "foo" }),
config.api.row.save(otherTable._id!, { one: "bar" }),
])
await Promise.all([
config.api.row.save(table._id!, {
two: "foo",
other: [rows[0]._id],
}),
config.api.row.save(table._id!, {
two: "bar",
other: [rows[1]._id],
}),
])
})
it("can search through relations", () =>
expectQuery({
equal: { [`${otherTable.name}.one`]: "foo" },
}).toContainExactly([{ two: "foo", other: [{ _id: rows[0]._id }] }]))
})
}) })

View file

@ -77,7 +77,7 @@ export function getTableParams(tableId?: Optional, otherProps = {}) {
* @returns The new table ID which the table doc can be stored under. * @returns The new table ID which the table doc can be stored under.
*/ */
export function generateTableID() { export function generateTableID() {
return `${DocumentType.TABLE}${SEPARATOR}${newid()}` return dbCore.generateTableID()
} }
/** /**

View file

@ -281,4 +281,40 @@ describe("mysql integrations", () => {
]) ])
}) })
}) })
describe("POST /api/datasources/:datasourceId/schema", () => {
let tableName: string
beforeEach(async () => {
tableName = uniqueTableName()
})
afterEach(async () => {
await rawQuery(rawDatasource, `DROP TABLE IF EXISTS \`${tableName}\``)
})
it("recognises enum columns as options", async () => {
const enumColumnName = "status"
const createTableQuery = `
CREATE TABLE \`${tableName}\` (
\`order_id\` INT AUTO_INCREMENT PRIMARY KEY,
\`customer_name\` VARCHAR(100) NOT NULL,
\`${enumColumnName}\` ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled')
);
`
await rawQuery(rawDatasource, createTableQuery)
const response = await makeRequest(
"post",
`/api/datasources/${datasource._id}/schema`
)
const table = response.body.datasource.entities[tableName]
expect(table).toBeDefined()
expect(table.schema[enumColumnName].type).toEqual(FieldType.OPTIONS)
})
})
}) })

View file

@ -1122,6 +1122,37 @@ describe("postgres integrations", () => {
[tableName]: "Table contains invalid columns.", [tableName]: "Table contains invalid columns.",
}) })
}) })
it("recognises enum columns as options", async () => {
const tableName = `orders_${generator
.guid()
.replaceAll("-", "")
.substring(0, 6)}`
const enumColumnName = "status"
await rawQuery(
rawDatasource,
`
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
CREATE TABLE ${tableName} (
order_id SERIAL PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
${enumColumnName} order_status
);
`
)
const response = await makeRequest(
"post",
`/api/datasources/${datasource._id}/schema`
)
const table = response.body.datasource.entities[tableName]
expect(table).toBeDefined()
expect(table.schema[enumColumnName].type).toEqual(FieldType.OPTIONS)
})
}) })
describe("Integration compatibility with postgres search_path", () => { describe("Integration compatibility with postgres search_path", () => {

View file

@ -122,11 +122,8 @@ function generateSelectStatement(
const fieldNames = field.split(/\./g) const fieldNames = field.split(/\./g)
const tableName = fieldNames[0] const tableName = fieldNames[0]
const columnName = fieldNames[1] const columnName = fieldNames[1]
if ( const columnSchema = schema?.[columnName]
columnName && if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
schema?.[columnName] &&
knex.client.config.client === SqlClient.POSTGRES
) {
const externalType = schema[columnName].externalType const externalType = schema[columnName].externalType
if (externalType?.includes("money")) { if (externalType?.includes("money")) {
return knex.raw( return knex.raw(
@ -134,6 +131,14 @@ function generateSelectStatement(
) )
} }
} }
if (
knex.client.config.client === SqlClient.MS_SQL &&
columnSchema?.type === FieldType.DATETIME &&
columnSchema.timeOnly
) {
// Time gets returned as timestamp from mssql, not matching the expected HH:mm format
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
}
return `${field} as ${field}` return `${field} as ${field}`
}) })
} }
@ -226,8 +231,7 @@ class InternalBuilder {
} }
const contains = (mode: object, any: boolean = false) => { const contains = (mode: object, any: boolean = false) => {
const fnc = allOr ? "orWhere" : "where" const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
const rawFnc = `${fnc}Raw`
const not = mode === filters?.notContains ? "NOT " : "" const not = mode === filters?.notContains ? "NOT " : ""
function stringifyArray(value: Array<any>, quoteStyle = '"'): string { function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
for (let i in value) { for (let i in value) {
@ -240,24 +244,24 @@ class InternalBuilder {
if (this.client === SqlClient.POSTGRES) { if (this.client === SqlClient.POSTGRES) {
iterate(mode, (key: string, value: Array<any>) => { iterate(mode, (key: string, value: Array<any>) => {
const wrap = any ? "" : "'" const wrap = any ? "" : "'"
const containsOp = any ? "\\?| array" : "@>" const op = any ? "\\?| array" : "@>"
const fieldNames = key.split(/\./g) const fieldNames = key.split(/\./g)
const tableName = fieldNames[0] const table = fieldNames[0]
const columnName = fieldNames[1] const col = fieldNames[1]
// @ts-ignore
query = query[rawFnc]( query = query[rawFnc](
`${not}"${tableName}"."${columnName}"::jsonb ${containsOp} ${wrap}${stringifyArray( `${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
value, value,
any ? "'" : '"' any ? "'" : '"'
)}${wrap}` )}${wrap}, FALSE)`
) )
}) })
} else if (this.client === SqlClient.MY_SQL) { } else if (this.client === SqlClient.MY_SQL) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS" const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, (key: string, value: Array<any>) => { iterate(mode, (key: string, value: Array<any>) => {
// @ts-ignore
query = query[rawFnc]( query = query[rawFnc](
`${not}${jsonFnc}(${key}, '${stringifyArray(value)}')` `${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value
)}'), FALSE)`
) )
}) })
} else { } else {
@ -272,7 +276,7 @@ class InternalBuilder {
} }
statement += statement +=
(statement ? andOr : "") + (statement ? andOr : "") +
`LOWER(${likeKey(this.client, key)}) LIKE ?` `COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
} }
if (statement === "") { if (statement === "") {
@ -337,14 +341,34 @@ class InternalBuilder {
} }
if (filters.equal) { if (filters.equal) {
iterate(filters.equal, (key, value) => { iterate(filters.equal, (key, value) => {
const fnc = allOr ? "orWhere" : "where" const fnc = allOr ? "orWhereRaw" : "whereRaw"
query = query[fnc]({ [key]: value }) if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
[value]
)
}
}) })
} }
if (filters.notEqual) { if (filters.notEqual) {
iterate(filters.notEqual, (key, value) => { iterate(filters.notEqual, (key, value) => {
const fnc = allOr ? "orWhereNot" : "whereNot" const fnc = allOr ? "orWhereRaw" : "whereRaw"
query = query[fnc]({ [key]: value }) if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
[value]
)
}
}) })
} }
if (filters.empty) { if (filters.empty) {
@ -383,7 +407,13 @@ class InternalBuilder {
for (let [key, value] of Object.entries(sort)) { for (let [key, value] of Object.entries(sort)) {
const direction = const direction =
value.direction === SortDirection.ASCENDING ? "asc" : "desc" value.direction === SortDirection.ASCENDING ? "asc" : "desc"
query = query.orderBy(`${aliased}.${key}`, direction) let nulls
if (this.client === SqlClient.POSTGRES) {
// All other clients already sort this as expected by default, and adding this to the rest of the clients is causing issues
nulls = value.direction === SortDirection.ASCENDING ? "first" : "last"
}
query = query.orderBy(`${aliased}.${key}`, direction, nulls)
} }
} else if (this.client === SqlClient.MS_SQL && paginate?.limit) { } else if (this.client === SqlClient.MS_SQL && paginate?.limit) {
// @ts-ignore // @ts-ignore
@ -634,12 +664,13 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
*/ */
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] { _query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
const sqlClient = this.getSqlClient() const sqlClient = this.getSqlClient()
const config: { client: string; useNullAsDefault?: boolean } = { const config: Knex.Config = {
client: sqlClient, client: sqlClient,
} }
if (sqlClient === SqlClient.SQL_LITE) { if (sqlClient === SqlClient.SQL_LITE) {
config.useNullAsDefault = true config.useNullAsDefault = true
} }
const client = knex(config) const client = knex(config)
let query: Knex.QueryBuilder let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient) const builder = new InternalBuilder(sqlClient)

View file

@ -79,9 +79,13 @@ function generateSchema(
schema.boolean(key) schema.boolean(key)
break break
case FieldType.DATETIME: case FieldType.DATETIME:
schema.datetime(key, { if (!column.timeOnly) {
useTz: !column.ignoreTimezones, schema.datetime(key, {
}) useTz: !column.ignoreTimezones,
})
} else {
schema.time(key)
}
break break
case FieldType.ARRAY: case FieldType.ARRAY:
case FieldType.BB_REFERENCE: case FieldType.BB_REFERENCE:

View file

@ -329,14 +329,12 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
// Fetch enum values // Fetch enum values
const enumsResponse = await this.client.query(this.ENUM_VALUES()) const enumsResponse = await this.client.query(this.ENUM_VALUES())
// output array, allows for more than 1 single-select to be used at a time
const enumValues = enumsResponse.rows?.reduce((acc, row) => { const enumValues = enumsResponse.rows?.reduce((acc, row) => {
if (!acc[row.typname]) { return {
return { ...acc,
[row.typname]: [row.enumlabel], [row.typname]: [...(acc[row.typname] || []), row.enumlabel],
}
} }
acc[row.typname].push(row.enumlabel)
return acc
}, {}) }, {})
for (let column of columnsResponse.rows) { for (let column of columnsResponse.rows) {

View file

@ -56,16 +56,6 @@ function generateReadJson({
} }
} }
function generateCreateJson(table = TABLE_NAME, body = {}): QueryJson {
return {
endpoint: endpoint(table, "CREATE"),
meta: {
table: TABLE,
},
body,
}
}
function generateRelationshipJson(config: { schema?: string } = {}): QueryJson { function generateRelationshipJson(config: { schema?: string } = {}): QueryJson {
return { return {
endpoint: { endpoint: {
@ -146,24 +136,6 @@ describe("SQL query builder", () => {
sql = new Sql(client, limit) sql = new Sql(client, limit)
}) })
it("should allow filtering on a related field", () => {
const query = sql._query(
generateReadJson({
filters: {
equal: {
age: 10,
"task.name": "task 1",
},
},
})
)
// order of bindings changes because relationship filters occur outside inner query
expect(query).toEqual({
bindings: [10, limit, "task 1"],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age" = $1 limit $2) as "${TABLE_NAME}" where "task"."name" = $3`,
})
})
it("should add the schema to the LEFT JOIN", () => { it("should add the schema to the LEFT JOIN", () => {
const query = sql._query(generateRelationshipJson({ schema: "production" })) const query = sql._query(generateRelationshipJson({ schema: "production" }))
expect(query).toEqual({ expect(query).toEqual({
@ -190,44 +162,6 @@ describe("SQL query builder", () => {
}) })
}) })
it("should ignore high range value if it is an empty object", () => {
const query = sql._query(
generateReadJson({
filters: {
range: {
dob: {
low: "2000-01-01 00:00:00",
high: {},
},
},
},
})
)
expect(query).toEqual({
bindings: ["2000-01-01 00:00:00", 500],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" >= $1 limit $2) as "${TABLE_NAME}"`,
})
})
it("should ignore low range value if it is an empty object", () => {
const query = sql._query(
generateReadJson({
filters: {
range: {
dob: {
low: {},
high: "2010-01-01 00:00:00",
},
},
},
})
)
expect(query).toEqual({
bindings: ["2010-01-01 00:00:00", 500],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" <= $1 limit $2) as "${TABLE_NAME}"`,
})
})
it("should lowercase the values for Oracle LIKE statements", () => { it("should lowercase the values for Oracle LIKE statements", () => {
let query = new Sql(SqlClient.ORACLE, limit)._query( let query = new Sql(SqlClient.ORACLE, limit)._query(
generateReadJson({ generateReadJson({
@ -255,7 +189,7 @@ describe("SQL query builder", () => {
) )
expect(query).toEqual({ expect(query).toEqual({
bindings: ["%20%", "%25%", `%"john"%`, `%"mary"%`, limit], bindings: ["%20%", "%25%", `%"john"%`, `%"mary"%`, limit],
sql: `select * from (select * from (select * from "test" where (LOWER("test"."age") LIKE :1 AND LOWER("test"."age") LIKE :2) and (LOWER("test"."name") LIKE :3 AND LOWER("test"."name") LIKE :4)) where rownum <= :5) "test"`, sql: `select * from (select * from (select * from "test" where (COALESCE(LOWER("test"."age"), '') LIKE :1 AND COALESCE(LOWER("test"."age"), '') LIKE :2) and (COALESCE(LOWER("test"."name"), '') LIKE :3 AND COALESCE(LOWER("test"."name"), '') LIKE :4)) where rownum <= :5) "test"`,
}) })
query = new Sql(SqlClient.ORACLE, limit)._query( query = new Sql(SqlClient.ORACLE, limit)._query(
@ -272,44 +206,4 @@ describe("SQL query builder", () => {
sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`, sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`,
}) })
}) })
it("should sort SQL Server tables by the primary key if no sort data is provided", () => {
let query = new Sql(SqlClient.MS_SQL, limit)._query(
generateReadJson({
sort: {},
paginate: {
limit: 10,
},
})
)
expect(query).toEqual({
bindings: [10],
sql: `select * from (select top (@p0) * from [test] order by [test].[id] asc) as [test]`,
})
})
it("should not parse JSON string as Date", () => {
let query = new Sql(SqlClient.POSTGRES, limit)._query(
generateCreateJson(TABLE_NAME, {
name: '{ "created_at":"2023-09-09T03:21:06.024Z" }',
})
)
expect(query).toEqual({
bindings: ['{ "created_at":"2023-09-09T03:21:06.024Z" }'],
sql: `insert into "test" ("name") values ($1) returning *`,
})
})
it("should parse and trim valid string as Date", () => {
const dateObj = new Date("2023-09-09T03:21:06.024Z")
let query = new Sql(SqlClient.POSTGRES, limit)._query(
generateCreateJson(TABLE_NAME, {
name: " 2023-09-09T03:21:06.024Z ",
})
)
expect(query).toEqual({
bindings: [dateObj],
sql: `insert into "test" ("name") values ($1) returning *`,
})
})
}) })

View file

@ -61,9 +61,9 @@ describe("Captures of real examples", () => {
"b"."taskid" as "b.taskid", "b"."completed" as "b.completed", "b"."qaid" as "b.qaid", "b"."taskid" as "b.taskid", "b"."completed" as "b.completed", "b"."qaid" as "b.qaid",
"b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid", "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
"b"."completed" as "b.completed", "b"."qaid" as "b.qaid" "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
from (select * from "persons" as "a" order by "a"."firstname" asc limit $1) as "a" from (select * from "persons" as "a" order by "a"."firstname" asc nulls first limit $1) as "a"
left join "tasks" as "b" on "a"."personid" = "b"."qaid" or "a"."personid" = "b"."executorid" left join "tasks" as "b" on "a"."personid" = "b"."qaid" or "a"."personid" = "b"."executorid"
order by "a"."firstname" asc limit $2`), order by "a"."firstname" asc nulls first limit $2`),
}) })
}) })
@ -75,10 +75,10 @@ describe("Captures of real examples", () => {
sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid", sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid",
"b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid", "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
"b"."completed" as "b.completed", "b"."qaid" as "b.qaid" "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
from (select * from "products" as "a" order by "a"."productname" asc limit $1) as "a" from (select * from "products" as "a" order by "a"."productname" asc nulls first limit $1) as "a"
left join "products_tasks" as "c" on "a"."productid" = "c"."productid" left join "products_tasks" as "c" on "a"."productid" = "c"."productid"
left join "tasks" as "b" on "b"."taskid" = "c"."taskid" where "b"."taskname" = $2 left join "tasks" as "b" on "b"."taskid" = "c"."taskid" where COALESCE("b"."taskname" = $2, FALSE)
order by "a"."productname" asc limit $3`), order by "a"."productname" asc nulls first limit $3`),
}) })
}) })
@ -90,10 +90,10 @@ describe("Captures of real examples", () => {
sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid", sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid",
"b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid", "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
"b"."completed" as "b.completed", "b"."qaid" as "b.qaid" "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
from (select * from "products" as "a" order by "a"."productname" asc limit $1) as "a" from (select * from "products" as "a" order by "a"."productname" asc nulls first limit $1) as "a"
left join "products_tasks" as "c" on "a"."productid" = "c"."productid" left join "products_tasks" as "c" on "a"."productid" = "c"."productid"
left join "tasks" as "b" on "b"."taskid" = "c"."taskid" left join "tasks" as "b" on "b"."taskid" = "c"."taskid"
order by "a"."productname" asc limit $2`), order by "a"."productname" asc nulls first limit $2`),
}) })
}) })
@ -137,12 +137,12 @@ describe("Captures of real examples", () => {
"c"."city" as "c.city", "c"."lastname" as "c.lastname", "c"."year" as "c.year", "c"."firstname" as "c.firstname", "c"."city" as "c.city", "c"."lastname" as "c.lastname", "c"."year" as "c.year", "c"."firstname" as "c.firstname",
"c"."personid" as "c.personid", "c"."address" as "c.address", "c"."age" as "c.age", "c"."type" as "c.type", "c"."personid" as "c.personid", "c"."address" as "c.address", "c"."age" as "c.age", "c"."type" as "c.type",
"c"."city" as "c.city", "c"."lastname" as "c.lastname" "c"."city" as "c.city", "c"."lastname" as "c.lastname"
from (select * from "tasks" as "a" where not "a"."completed" = $1 from (select * from "tasks" as "a" where COALESCE("a"."completed" != $1, TRUE)
order by "a"."taskname" asc limit $2) as "a" order by "a"."taskname" asc nulls first limit $2) as "a"
left join "products_tasks" as "d" on "a"."taskid" = "d"."taskid" left join "products_tasks" as "d" on "a"."taskid" = "d"."taskid"
left join "products" as "b" on "b"."productid" = "d"."productid" left join "products" as "b" on "b"."productid" = "d"."productid"
left join "persons" as "c" on "a"."executorid" = "c"."personid" or "a"."qaid" = "c"."personid" left join "persons" as "c" on "a"."executorid" = "c"."personid" or "a"."qaid" = "c"."personid"
where "c"."year" between $3 and $4 and "b"."productname" = $5 order by "a"."taskname" asc limit $6`), where "c"."year" between $3 and $4 and COALESCE("b"."productname" = $5, FALSE) order by "a"."taskname" asc nulls first limit $6`),
}) })
}) })
}) })
@ -154,7 +154,7 @@ describe("Captures of real examples", () => {
expect(query).toEqual({ expect(query).toEqual({
bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5], bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4, sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4,
"type" = $5, "city" = $6, "lastname" = $7 where "a"."personid" = $8 returning *`), "type" = $5, "city" = $6, "lastname" = $7 where COALESCE("a"."personid" = $8, FALSE) returning *`),
}) })
}) })
@ -164,7 +164,7 @@ describe("Captures of real examples", () => {
expect(query).toEqual({ expect(query).toEqual({
bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5], bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4, sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4,
"type" = $5, "city" = $6, "lastname" = $7 where "a"."personid" = $8 returning *`), "type" = $5, "city" = $6, "lastname" = $7 where COALESCE("a"."personid" = $8, FALSE) returning *`),
}) })
}) })
}) })
@ -175,8 +175,9 @@ describe("Captures of real examples", () => {
let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson) let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
expect(query).toEqual({ expect(query).toEqual({
bindings: ["ddd", ""], bindings: ["ddd", ""],
sql: multiline(`delete from "compositetable" as "a" where "a"."keypartone" = $1 and "a"."keyparttwo" = $2 sql: multiline(`delete from "compositetable" as "a"
returning "a"."keyparttwo" as "a.keyparttwo", "a"."keypartone" as "a.keypartone", "a"."name" as "a.name"`), where COALESCE("a"."keypartone" = $1, FALSE) and COALESCE("a"."keyparttwo" = $2, FALSE)
returning "a"."keyparttwo" as "a.keyparttwo", "a"."keypartone" as "a.keypartone", "a"."name" as "a.name"`),
}) })
}) })
}) })
@ -197,7 +198,7 @@ describe("Captures of real examples", () => {
returningQuery = input returningQuery = input
}, queryJson) }, queryJson)
expect(returningQuery).toEqual({ expect(returningQuery).toEqual({
sql: "select * from (select top (@p0) * from [people] where [people].[name] = @p1 and [people].[age] = @p2 order by [people].[name] asc) as [people]", sql: "select * from (select top (@p0) * from [people] where CASE WHEN [people].[name] = @p1 THEN 1 ELSE 0 END = 1 and CASE WHEN [people].[age] = @p2 THEN 1 ELSE 0 END = 1 order by [people].[name] asc) as [people]",
bindings: [1, "Test", 22], bindings: [1, "Test", 22],
}) })
}) })

View file

@ -71,7 +71,11 @@ const SQL_DATE_TYPE_MAP: Record<string, PrimitiveTypes> = {
} }
const SQL_DATE_ONLY_TYPES = ["date"] const SQL_DATE_ONLY_TYPES = ["date"]
const SQL_TIME_ONLY_TYPES = ["time"] const SQL_TIME_ONLY_TYPES = [
"time",
"time without time zone",
"time with time zone",
]
const SQL_STRING_TYPE_MAP: Record<string, PrimitiveTypes> = { const SQL_STRING_TYPE_MAP: Record<string, PrimitiveTypes> = {
varchar: FieldType.STRING, varchar: FieldType.STRING,
@ -98,6 +102,7 @@ const SQL_OPTIONS_TYPE_MAP: Record<string, PrimitiveTypes> = {
const SQL_MISC_TYPE_MAP: Record<string, PrimitiveTypes> = { const SQL_MISC_TYPE_MAP: Record<string, PrimitiveTypes> = {
json: FieldType.JSON, json: FieldType.JSON,
bigint: FieldType.BIGINT, bigint: FieldType.BIGINT,
enum: FieldType.OPTIONS,
} }
const SQL_TYPE_MAP: Record<string, PrimitiveTypes> = { const SQL_TYPE_MAP: Record<string, PrimitiveTypes> = {

View file

@ -55,8 +55,8 @@ function buildInternalFieldList(
return fieldList return fieldList
} }
function tableInFilter(name: string) { function tableNameInFieldRegex(tableName: string) {
return `:${name}.` return new RegExp(`^${tableName}.|:${tableName}.`, "g")
} }
function cleanupFilters(filters: SearchFilters, tables: Table[]) { function cleanupFilters(filters: SearchFilters, tables: Table[]) {
@ -72,15 +72,13 @@ function cleanupFilters(filters: SearchFilters, tables: Table[]) {
// relationship, switch to table ID // relationship, switch to table ID
const tableRelated = tables.find( const tableRelated = tables.find(
table => table =>
table.originalName && key.includes(tableInFilter(table.originalName)) table.originalName &&
key.match(tableNameInFieldRegex(table.originalName))
) )
if (tableRelated && tableRelated.originalName) { if (tableRelated && tableRelated.originalName) {
filter[ // only replace the first, not replaceAll
key.replace( filter[key.replace(tableRelated.originalName, tableRelated._id!)] =
tableInFilter(tableRelated.originalName), filter[key]
tableInFilter(tableRelated._id!)
)
] = filter[key]
delete filter[key] delete filter[key]
} }
} }
@ -173,7 +171,8 @@ export async function search(
sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`") sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`")
const db = context.getAppDB() const db = context.getAppDB()
return await db.sql<Row>(sql, bindings) const rows = await db.sql<Row>(sql, bindings)
return rows
}) })
// process from the format of tableId.column to expected format // process from the format of tableId.column to expected format

View file

@ -126,16 +126,25 @@ export default class AliasTables {
} }
reverse<T extends Row | Row[]>(rows: T): T { reverse<T extends Row | Row[]>(rows: T): T {
const mapping = new Map()
const process = (row: Row) => { const process = (row: Row) => {
const final: Row = {} const final: Row = {}
for (let [key, value] of Object.entries(row)) { for (const key of Object.keys(row)) {
if (!key.includes(".")) { let mappedKey = mapping.get(key)
final[key] = value if (!mappedKey) {
} else { const dotLocation = key.indexOf(".")
const [alias, column] = key.split(".") if (dotLocation === -1) {
const tableName = this.tableAliases[alias] || alias mappedKey = key
final[`${tableName}.${column}`] = value } else {
const alias = key.slice(0, dotLocation)
const column = key.slice(dotLocation + 1)
const tableName = this.tableAliases[alias] || alias
mappedKey = `${tableName}.${column}`
}
mapping.set(key, mappedKey)
} }
final[mappedKey] = row[key]
} }
return final return final
} }

View file

@ -0,0 +1,335 @@
import dayjs from "dayjs"
import {
FieldType,
INTERNAL_TABLE_SOURCE_ID,
Table,
TableSourceType,
} from "@budibase/types"
import { generateTableID } from "../../../../db/utils"
import { validate } from "../utils"
import { generator } from "@budibase/backend-core/tests"
describe("validate", () => {
const hour = () => generator.hour().toString().padStart(2, "0")
const minute = () => generator.minute().toString().padStart(2, "0")
const second = minute
describe("time only", () => {
const getTable = (): Table => ({
type: "table",
_id: generateTableID(),
name: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
time: {
name: "time",
type: FieldType.DATETIME,
timeOnly: true,
},
},
})
it("should accept empty values", async () => {
const row = {}
const table = getTable()
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
expect(output.errors).toEqual({})
})
it("should accept valid times with HH:mm format", async () => {
const row = {
time: `${hour()}:${minute()}`,
}
const table = getTable()
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
})
it("should accept valid times with HH:mm:ss format", async () => {
const row = {
time: `${hour()}:${minute()}:${second()}`,
}
const table = getTable()
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
})
it.each([
["ISO datetimes", generator.date().toISOString()],
["random values", generator.word()],
])("should reject %s", async (_, time) => {
const row = {
time,
}
const table = getTable()
table.schema.time.constraints = {
presence: true,
}
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({ time: ['"time" is not a valid time'] })
})
describe("time constraints", () => {
describe("earliest only", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: "10:00",
latest: "",
},
}
it.each([
"10:00",
"15:00",
`10:${minute()}`,
"12:34",
`${generator.integer({ min: 11, max: 23 })}:${minute()}`,
])("should accept values after config value (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
})
it.each([
"09:59:59",
`${generator.integer({ min: 0, max: 9 })}:${minute()}`,
])("should reject values before config value (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no earlier than 10:00"],
})
})
})
describe("latest only", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: "",
latest: "15:16:17",
},
}
it.each([
"15:16:17",
"15:16",
"15:00",
`${generator.integer({ min: 0, max: 12 })}:${minute()}`,
])("should accept values before config value (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
})
it.each([
"15:16:18",
`${generator.integer({ min: 16, max: 23 })}:${minute()}`,
])("should reject values after config value (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no later than 15:16:17"],
})
})
})
describe("range", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: "10:00",
latest: "15:00",
},
}
it.each(["10:00", "15:00", `10:${minute()}`, "12:34"])(
"should accept values in range (%s)",
async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
}
)
it.each([
"9:59:50",
`${generator.integer({ min: 0, max: 9 })}:${minute()}`,
])("should reject values before range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no earlier than 10:00"],
})
})
it.each([
"15:00:01",
`${generator.integer({ min: 16, max: 23 })}:${minute()}`,
])("should reject values after range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no later than 15:00"],
})
})
describe("range crossing midnight", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: "15:00",
latest: "10:00",
},
}
it.each(["10:00", "15:00", `9:${minute()}`, "16:34", "00:00"])(
"should accept values in range (%s)",
async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
}
)
it.each(["10:01", "14:59:59", `12:${minute()}`])(
"should reject values out range (%s)",
async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no later than 10:00"],
})
}
)
})
})
})
describe("required", () => {
it("should reject empty values", async () => {
const row = {}
const table = getTable()
table.schema.time.constraints = {
presence: true,
}
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({ time: ["can't be blank"] })
})
it.each([undefined, null])("should reject %s values", async time => {
const row = { time }
const table = getTable()
table.schema.time.constraints = {
presence: true,
}
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({ time: ["can't be blank"] })
})
})
describe("range", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: "10:00",
latest: "15:00",
},
}
it.each(["10:00", "15:00", `10:${minute()}`, "12:34"])(
"should accept values in range (%s)",
async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
}
)
it.each([
"9:59:50",
`${generator.integer({ min: 0, max: 9 })}:${minute()}`,
])("should reject values before range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no earlier than 10:00"],
})
})
it.each([
"15:00:01",
`${generator.integer({ min: 16, max: 23 })}:${minute()}`,
])("should reject values after range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no later than 15:00"],
})
})
describe("datetime ISO configs", () => {
const table = getTable()
table.schema.time.constraints = {
presence: true,
datetime: {
earliest: dayjs().hour(10).minute(0).second(0).toISOString(),
latest: dayjs().hour(15).minute(0).second(0).toISOString(),
},
}
it.each(["10:00", "15:00", `12:${minute()}`])(
"should accept values in range (%s)",
async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(true)
}
)
it.each([
"09:59:50",
`${generator.integer({ min: 0, max: 9 })}:${minute()}`,
])("should reject values before range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no earlier than 10:00"],
})
})
it.each([
"15:00:01",
`${generator.integer({ min: 16, max: 23 })}:${minute()}`,
])("should reject values after range (%s)", async time => {
const row = { time }
const output = await validate({ table, tableId: table._id!, row })
expect(output.valid).toBe(false)
expect(output.errors).toEqual({
time: ["must be no later than 15:00"],
})
})
})
})
})
})

View file

@ -1,8 +1,10 @@
import cloneDeep from "lodash/cloneDeep"
import validateJs from "validate.js" import validateJs from "validate.js"
import dayjs from "dayjs"
import cloneDeep from "lodash/fp/cloneDeep"
import { import {
Datasource, Datasource,
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
FieldConstraints,
FieldType, FieldType,
QueryJson, QueryJson,
Row, Row,
@ -205,6 +207,8 @@ export async function validate({
} catch (err) { } catch (err) {
errors[fieldName] = [`Contains invalid JSON`] errors[fieldName] = [`Contains invalid JSON`]
} }
} else if (type === FieldType.DATETIME && column.timeOnly) {
res = validateTimeOnlyField(fieldName, row[fieldName], constraints)
} else { } else {
res = validateJs.single(row[fieldName], constraints) res = validateJs.single(row[fieldName], constraints)
} }
@ -212,3 +216,86 @@ export async function validate({
} }
return { valid: Object.keys(errors).length === 0, errors } return { valid: Object.keys(errors).length === 0, errors }
} }
function validateTimeOnlyField(
fieldName: string,
value: any,
constraints: FieldConstraints | undefined
) {
let res
if (value && !value.match(/^(\d+)(:[0-5]\d){1,2}$/)) {
res = [`"${fieldName}" is not a valid time`]
} else if (constraints) {
let castedValue = value
const stringTimeToDate = (value: string) => {
const [hour, minute, second] = value.split(":").map((x: string) => +x)
let date = dayjs("2000-01-01T00:00:00.000Z").hour(hour).minute(minute)
if (!isNaN(second)) {
date = date.second(second)
}
return date
}
if (castedValue) {
castedValue = stringTimeToDate(castedValue)
}
let castedConstraints = cloneDeep(constraints)
let earliest, latest
let easliestTimeString: string, latestTimeString: string
if (castedConstraints.datetime?.earliest) {
easliestTimeString = castedConstraints.datetime.earliest
if (dayjs(castedConstraints.datetime.earliest).isValid()) {
easliestTimeString = dayjs(castedConstraints.datetime.earliest).format(
"HH:mm"
)
}
earliest = stringTimeToDate(easliestTimeString)
}
if (castedConstraints.datetime?.latest) {
latestTimeString = castedConstraints.datetime.latest
if (dayjs(castedConstraints.datetime.latest).isValid()) {
latestTimeString = dayjs(castedConstraints.datetime.latest).format(
"HH:mm"
)
}
latest = stringTimeToDate(latestTimeString)
}
if (earliest && latest && earliest.isAfter(latest)) {
latest = latest.add(1, "day")
if (earliest.isAfter(castedValue)) {
castedValue = castedValue.add(1, "day")
}
}
if (earliest || latest) {
castedConstraints.datetime = {
earliest: earliest?.toISOString() || "",
latest: latest?.toISOString() || "",
}
}
let jsValidation = validateJs.single(
castedValue?.toISOString(),
castedConstraints
)
jsValidation = jsValidation?.map((m: string) =>
m
?.replace(
castedConstraints.datetime?.earliest || "",
easliestTimeString || ""
)
.replace(
castedConstraints.datetime?.latest || "",
latestTimeString || ""
)
)
if (jsValidation) {
res ??= []
res.push(...jsValidation)
}
}
return res
}

View file

@ -73,6 +73,16 @@ function validate(table: Table, oldTable?: Table) {
`Column "${key}" has subtype "${column.subtype}" - this is not supported.` `Column "${key}" has subtype "${column.subtype}" - this is not supported.`
) )
} }
if (column.type === FieldType.DATETIME) {
const oldColumn = oldTable?.schema[key] as typeof column
if (oldColumn && column.timeOnly !== oldColumn.timeOnly) {
throw new Error(
`Column "${key}" can not change from time to datetime or viceversa.`
)
}
}
} }
} }

View file

@ -37,7 +37,7 @@ export function tableForDatasource(
): Table { ): Table {
return merge( return merge(
{ {
name: generator.guid(), name: generator.guid().substring(0, 10),
type: "table", type: "table",
sourceType: datasource sourceType: datasource
? TableSourceType.EXTERNAL ? TableSourceType.EXTERNAL

View file

@ -105,6 +105,9 @@ export function processDates<T extends Row | Row[]>(
if (schema.type !== FieldType.DATETIME) { if (schema.type !== FieldType.DATETIME) {
continue continue
} }
if (schema.dateOnly) {
continue
}
if (!schema.timeOnly && !schema.ignoreTimezones) { if (!schema.timeOnly && !schema.ignoreTimezones) {
datesWithTZ.push(column) datesWithTZ.push(column)
} }

View file

@ -129,11 +129,16 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
return return
} }
const { type: columnType } = schema[columnName] const columnSchema = schema[columnName]
const { type: columnType } = columnSchema
if (columnType === FieldType.NUMBER) { if (columnType === FieldType.NUMBER) {
// If provided must be a valid number // If provided must be a valid number
parsedRow[columnName] = columnData ? Number(columnData) : columnData parsedRow[columnName] = columnData ? Number(columnData) : columnData
} else if (columnType === FieldType.DATETIME) { } else if (
columnType === FieldType.DATETIME &&
!columnSchema.timeOnly &&
!columnSchema.dateOnly
) {
// If provided must be a valid date // If provided must be a valid date
parsedRow[columnName] = columnData parsedRow[columnName] = columnData
? new Date(columnData).toISOString() ? new Date(columnData).toISOString()