Merge branch 'master' into fix/single-user-support-automations
This commit is contained in:
commit
bbeb6e88e7
|
@ -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 {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.26.4",
|
"version": "2.27.3",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -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 }] }]))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 *`,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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> = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue