forked from tsai/budibase
149 changed files with 3874 additions and 2521 deletions
@ -0,0 +1,3 @@ |
|||||
|
module.exports = { |
||||
|
user: require("./src/cache/user"), |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
module.exports = require("./src/security/sessions") |
||||
@ -0,0 +1,21 @@ |
|||||
|
const { getDB } = require("../db") |
||||
|
const { StaticDatabases } = require("../db/utils") |
||||
|
const redis = require("../redis/authRedis") |
||||
|
|
||||
|
const EXPIRY_SECONDS = 3600 |
||||
|
|
||||
|
exports.getUser = async userId => { |
||||
|
const client = await redis.getUserClient() |
||||
|
// try cache
|
||||
|
let user = await client.get(userId) |
||||
|
if (!user) { |
||||
|
user = await getDB(StaticDatabases.GLOBAL.name).get(userId) |
||||
|
client.store(userId, user, EXPIRY_SECONDS) |
||||
|
} |
||||
|
return user |
||||
|
} |
||||
|
|
||||
|
exports.invalidateUser = async userId => { |
||||
|
const client = await redis.getUserClient() |
||||
|
await client.delete(userId) |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
const Client = require("./index") |
||||
|
const utils = require("./utils") |
||||
|
|
||||
|
let userClient, sessionClient |
||||
|
|
||||
|
async function init() { |
||||
|
userClient = await new Client(utils.Databases.USER_CACHE).init() |
||||
|
sessionClient = await new Client(utils.Databases.SESSIONS).init() |
||||
|
} |
||||
|
|
||||
|
process.on("exit", async () => { |
||||
|
if (userClient) await userClient.finish() |
||||
|
if (sessionClient) await sessionClient.finish() |
||||
|
}) |
||||
|
|
||||
|
module.exports = { |
||||
|
getUserClient: async () => { |
||||
|
if (!userClient) { |
||||
|
await init() |
||||
|
} |
||||
|
return userClient |
||||
|
}, |
||||
|
getSessionClient: async () => { |
||||
|
if (!sessionClient) { |
||||
|
await init() |
||||
|
} |
||||
|
return sessionClient |
||||
|
}, |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
const redis = require("../redis/authRedis") |
||||
|
|
||||
|
const EXPIRY_SECONDS = 86400 |
||||
|
|
||||
|
async function getSessionsForUser(userId) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessions = await client.scan(userId) |
||||
|
return sessions.map(session => session.value) |
||||
|
} |
||||
|
|
||||
|
function makeSessionID(userId, sessionId) { |
||||
|
return `${userId}/${sessionId}` |
||||
|
} |
||||
|
|
||||
|
exports.createASession = async (userId, sessionId) => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const session = { |
||||
|
createdAt: new Date().toISOString(), |
||||
|
lastAccessedAt: new Date().toISOString(), |
||||
|
sessionId, |
||||
|
userId, |
||||
|
} |
||||
|
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
exports.invalidateSessions = async (userId, sessionId = null) => { |
||||
|
let sessions = [] |
||||
|
if (sessionId) { |
||||
|
sessions.push({ key: makeSessionID(userId, sessionId) }) |
||||
|
} else { |
||||
|
sessions = await getSessionsForUser(userId) |
||||
|
} |
||||
|
const client = await redis.getSessionClient() |
||||
|
const promises = [] |
||||
|
for (let session of sessions) { |
||||
|
promises.push(client.delete(session.key)) |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
|
||||
|
exports.updateSessionTTL = async session => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const key = makeSessionID(session.userId, session.sessionId) |
||||
|
session.lastAccessedAt = new Date().toISOString() |
||||
|
await client.store(key, session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
exports.endSession = async (userId, sessionId) => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
await client.delete(makeSessionID(userId, sessionId)) |
||||
|
} |
||||
|
|
||||
|
exports.getUserSessions = getSessionsForUser |
||||
|
|
||||
|
exports.getSession = async (userId, sessionId) => { |
||||
|
try { |
||||
|
const client = await redis.getSessionClient() |
||||
|
return client.get(makeSessionID(userId, sessionId)) |
||||
|
} catch (err) { |
||||
|
// if can't get session don't error, just don't return anything
|
||||
|
return null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
exports.getAllSessions = async () => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessions = await client.scan() |
||||
|
return sessions.map(session => session.value) |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
<script> |
||||
|
import { Icon, Combobox, Drawer, Button } from "@budibase/bbui" |
||||
|
import { |
||||
|
readableToRuntimeBinding, |
||||
|
runtimeToReadableBinding, |
||||
|
} from "builderStore/dataBinding" |
||||
|
import BindingPanel from "components/common/bindings/BindingPanel.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let panel = BindingPanel |
||||
|
export let value = "" |
||||
|
export let bindings = [] |
||||
|
export let title = "Bindings" |
||||
|
export let placeholder |
||||
|
export let label |
||||
|
export let disabled = false |
||||
|
export let options |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let bindingDrawer |
||||
|
$: tempValue = Array.isArray(value) ? value : [] |
||||
|
$: readableValue = runtimeToReadableBinding(bindings, value) |
||||
|
|
||||
|
const handleClose = () => { |
||||
|
onChange(tempValue) |
||||
|
bindingDrawer.hide() |
||||
|
} |
||||
|
|
||||
|
const onChange = value => { |
||||
|
dispatch("change", readableToRuntimeBinding(bindings, value)) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<Combobox |
||||
|
{label} |
||||
|
{disabled} |
||||
|
value={readableValue} |
||||
|
on:change={event => onChange(event.detail)} |
||||
|
{placeholder} |
||||
|
{options} |
||||
|
/> |
||||
|
{#if !disabled} |
||||
|
<div class="icon" on:click={bindingDrawer.show}> |
||||
|
<Icon size="S" name="FlashOn" /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
<Drawer bind:this={bindingDrawer} {title}> |
||||
|
<svelte:fragment slot="description"> |
||||
|
Add the objects on the left to enrich your text. |
||||
|
</svelte:fragment> |
||||
|
<Button cta slot="buttons" on:click={handleClose}>Save</Button> |
||||
|
<svelte:component |
||||
|
this={panel} |
||||
|
slot="body" |
||||
|
value={readableValue} |
||||
|
close={handleClose} |
||||
|
on:update={event => (tempValue = event.detail)} |
||||
|
bindableProperties={bindings} |
||||
|
/> |
||||
|
</Drawer> |
||||
|
|
||||
|
<style> |
||||
|
.control { |
||||
|
flex: 1; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.icon { |
||||
|
right: 31px; |
||||
|
bottom: 1px; |
||||
|
position: absolute; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
box-sizing: border-box; |
||||
|
border-left: 1px solid var(--spectrum-alias-border-color); |
||||
|
border-right: 1px solid var(--spectrum-alias-border-color); |
||||
|
width: 31px; |
||||
|
color: var(--spectrum-alias-text-color); |
||||
|
background-color: var(--spectrum-global-color-gray-75); |
||||
|
transition: background-color |
||||
|
var(--spectrum-global-animation-duration-100, 130ms), |
||||
|
box-shadow var(--spectrum-global-animation-duration-100, 130ms), |
||||
|
border-color var(--spectrum-global-animation-duration-100, 130ms); |
||||
|
height: calc(var(--spectrum-alias-item-height-m) - 2px); |
||||
|
} |
||||
|
|
||||
|
.icon:hover { |
||||
|
cursor: pointer; |
||||
|
color: var(--spectrum-alias-text-color-hover); |
||||
|
background-color: var(--spectrum-global-color-gray-50); |
||||
|
border-color: var(--spectrum-alias-border-color-hover); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,120 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Icon, |
||||
|
Modal, |
||||
|
notifications, |
||||
|
ModalContent, |
||||
|
Body, |
||||
|
Button, |
||||
|
} from "@budibase/bbui" |
||||
|
import { store } from "builderStore" |
||||
|
import api from "builderStore/api" |
||||
|
import clientPackage from "@budibase/client/package.json" |
||||
|
|
||||
|
let updateModal |
||||
|
|
||||
|
$: appId = $store.appId |
||||
|
$: updateAvailable = clientPackage.version !== $store.version |
||||
|
$: revertAvailable = $store.revertableVersion != null |
||||
|
|
||||
|
const refreshAppPackage = async () => { |
||||
|
const applicationPkg = await api.get( |
||||
|
`/api/applications/${appId}/appPackage` |
||||
|
) |
||||
|
const pkg = await applicationPkg.json() |
||||
|
if (applicationPkg.ok) { |
||||
|
await store.actions.initialise(pkg) |
||||
|
} else { |
||||
|
throw new Error(pkg) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const update = async () => { |
||||
|
try { |
||||
|
const response = await api.post( |
||||
|
`/api/applications/${appId}/client/update` |
||||
|
) |
||||
|
const json = await response.json() |
||||
|
if (response.status !== 200) { |
||||
|
throw json.message |
||||
|
} |
||||
|
|
||||
|
// Don't wait for the async refresh, since this causes modal flashing |
||||
|
refreshAppPackage() |
||||
|
notifications.success( |
||||
|
`App updated successfully to version ${clientPackage.version}` |
||||
|
) |
||||
|
} catch (err) { |
||||
|
notifications.error(`Error updating app: ${err}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const revert = async () => { |
||||
|
try { |
||||
|
const revertableVersion = $store.revertableVersion |
||||
|
const response = await api.post( |
||||
|
`/api/applications/${appId}/client/revert` |
||||
|
) |
||||
|
const json = await response.json() |
||||
|
if (response.status !== 200) { |
||||
|
throw json.message |
||||
|
} |
||||
|
|
||||
|
// Don't wait for the async refresh, since this causes modal flashing |
||||
|
refreshAppPackage() |
||||
|
notifications.success( |
||||
|
`App reverted successfully to version ${revertableVersion}` |
||||
|
) |
||||
|
} catch (err) { |
||||
|
notifications.error(`Error reverting app: ${err}`) |
||||
|
} |
||||
|
updateModal.hide() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="icon-wrapper" class:highlight={updateAvailable}> |
||||
|
<Icon name="Refresh" hoverable on:click={updateModal.show} /> |
||||
|
</div> |
||||
|
<Modal bind:this={updateModal}> |
||||
|
<ModalContent |
||||
|
title="App version" |
||||
|
confirmText="Update" |
||||
|
cancelText={updateAvailable ? "Cancel" : "Close"} |
||||
|
onConfirm={update} |
||||
|
showConfirmButton={updateAvailable} |
||||
|
> |
||||
|
<div slot="footer"> |
||||
|
{#if revertAvailable} |
||||
|
<Button quiet secondary on:click={revert}>Revert</Button> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{#if updateAvailable} |
||||
|
<Body size="S"> |
||||
|
This app is currently using version <b>{$store.version}</b>, but version |
||||
|
<b>{clientPackage.version}</b> is available. Updates can contain new features, |
||||
|
performance improvements and bug fixes. |
||||
|
</Body> |
||||
|
{:else} |
||||
|
<Body size="S"> |
||||
|
This app is currently using version <b>{$store.version}</b> which is the |
||||
|
latest version available. |
||||
|
</Body> |
||||
|
{/if} |
||||
|
{#if revertAvailable} |
||||
|
<Body size="S"> |
||||
|
You can revert this app to version |
||||
|
<b>{$store.revertableVersion}</b> |
||||
|
if you're experiencing issues with the current version. |
||||
|
</Body> |
||||
|
{/if} |
||||
|
</ModalContent> |
||||
|
</Modal> |
||||
|
|
||||
|
<style> |
||||
|
.icon-wrapper { |
||||
|
display: contents; |
||||
|
} |
||||
|
.icon-wrapper.highlight :global(svg) { |
||||
|
color: var(--spectrum-global-color-blue-600); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { store } from "builderStore" |
||||
|
|
||||
|
const themeOptions = [ |
||||
|
{ |
||||
|
label: "Lightest", |
||||
|
value: "spectrum--lightest", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Light", |
||||
|
value: "spectrum--light", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Dark", |
||||
|
value: "spectrum--dark", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Darkest", |
||||
|
value: "spectrum--darkest", |
||||
|
}, |
||||
|
] |
||||
|
</script> |
||||
|
|
||||
|
<div> |
||||
|
<Select |
||||
|
value={$store.theme || "spectrum--light"} |
||||
|
options={themeOptions} |
||||
|
placeholder={null} |
||||
|
on:change={e => store.actions.theme.save(e.detail)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
padding-right: 8px; |
||||
|
} |
||||
|
</style> |
||||
@ -1,42 +1,8 @@ |
|||||
<script> |
<script> |
||||
import { createEventDispatcher } from "svelte" |
import { ColorPicker } from "@budibase/bbui" |
||||
import Colorpicker from "@budibase/colorpicker" |
import { store } from "builderStore" |
||||
|
|
||||
const dispatch = createEventDispatcher() |
|
||||
|
|
||||
export let value |
export let value |
||||
|
|
||||
const WAIT = 150 |
|
||||
|
|
||||
function throttle(callback, wait, immediate = false) { |
|
||||
let timeout = null |
|
||||
let initialCall = true |
|
||||
|
|
||||
return function () { |
|
||||
const callNow = immediate && initialCall |
|
||||
const next = () => { |
|
||||
callback.apply(this, arguments) |
|
||||
timeout = null |
|
||||
} |
|
||||
|
|
||||
if (callNow) { |
|
||||
initialCall = false |
|
||||
next() |
|
||||
} |
|
||||
|
|
||||
if (!timeout) { |
|
||||
timeout = setTimeout(next, wait) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const onChange = throttle( |
|
||||
e => { |
|
||||
dispatch("change", e.detail) |
|
||||
}, |
|
||||
WAIT, |
|
||||
true |
|
||||
) |
|
||||
</script> |
</script> |
||||
|
|
||||
<Colorpicker value={value || "#C4C4C4"} on:change={onChange} /> |
<ColorPicker {value} on:change spectrumTheme={$store.theme} /> |
||||
|
|||||
@ -0,0 +1,12 @@ |
|||||
|
<script> |
||||
|
import { store } from "builderStore" |
||||
|
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte" |
||||
|
|
||||
|
export let value |
||||
|
|
||||
|
$: urlOptions = $store.screens |
||||
|
.map(screen => screen.routing?.route) |
||||
|
.filter(x => x != null) |
||||
|
</script> |
||||
|
|
||||
|
<DrawerBindableCombobox {value} on:change options={urlOptions} /> |
||||
@ -0,0 +1,286 @@ |
|||||
|
<script> |
||||
|
import { RelationshipTypes } from "constants/backend" |
||||
|
import { |
||||
|
Button, |
||||
|
Input, |
||||
|
ModalContent, |
||||
|
Select, |
||||
|
Detail, |
||||
|
Body, |
||||
|
} from "@budibase/bbui" |
||||
|
import { tables } from "stores/backend" |
||||
|
import { uuid } from "builderStore/uuid" |
||||
|
import { writable } from "svelte/store" |
||||
|
|
||||
|
export let save |
||||
|
export let datasource |
||||
|
export let plusTables = [] |
||||
|
export let fromRelationship = {} |
||||
|
export let toRelationship = {} |
||||
|
export let close |
||||
|
|
||||
|
let originalFromName = fromRelationship.name, |
||||
|
originalToName = toRelationship.name |
||||
|
|
||||
|
function inSchema(table, prop, ogName) { |
||||
|
if (!table || !prop || prop === ogName) { |
||||
|
return false |
||||
|
} |
||||
|
const keys = Object.keys(table.schema).map(key => key.toLowerCase()) |
||||
|
return keys.indexOf(prop.toLowerCase()) !== -1 |
||||
|
} |
||||
|
|
||||
|
const touched = writable({}) |
||||
|
|
||||
|
function checkForErrors( |
||||
|
fromTable, |
||||
|
toTable, |
||||
|
throughTable, |
||||
|
fromRelate, |
||||
|
toRelate |
||||
|
) { |
||||
|
const isMany = |
||||
|
fromRelate.relationshipType === RelationshipTypes.MANY_TO_MANY |
||||
|
const tableNotSet = "Please specify a table" |
||||
|
const errors = {} |
||||
|
if ($touched.from && !fromTable) { |
||||
|
errors.from = tableNotSet |
||||
|
} |
||||
|
if ($touched.to && !toTable) { |
||||
|
errors.to = tableNotSet |
||||
|
} |
||||
|
if ($touched.through && isMany && !fromRelate.through) { |
||||
|
errors.through = tableNotSet |
||||
|
} |
||||
|
if ($touched.foreign && !isMany && !fromRelate.fieldName) { |
||||
|
errors.foreign = "Please pick the foreign key" |
||||
|
} |
||||
|
const colNotSet = "Please specify a column name" |
||||
|
if ($touched.fromCol && !fromRelate.name) { |
||||
|
errors.fromCol = colNotSet |
||||
|
} |
||||
|
if ($touched.toCol && !toRelate.name) { |
||||
|
errors.toCol = colNotSet |
||||
|
} |
||||
|
// currently don't support relationships back onto the table itself, needs to relate out |
||||
|
const tableError = "From/to/through tables must be different" |
||||
|
if (fromTable && (fromTable === toTable || fromTable === throughTable)) { |
||||
|
errors.from = tableError |
||||
|
} |
||||
|
if (toTable && (toTable === fromTable || toTable === throughTable)) { |
||||
|
errors.to = tableError |
||||
|
} |
||||
|
if ( |
||||
|
throughTable && |
||||
|
(throughTable === fromTable || throughTable === toTable) |
||||
|
) { |
||||
|
errors.through = tableError |
||||
|
} |
||||
|
const colError = "Column name cannot be an existing column" |
||||
|
if (inSchema(fromTable, fromRelate.name, originalFromName)) { |
||||
|
errors.fromCol = colError |
||||
|
} |
||||
|
if (inSchema(toTable, toRelate.name, originalToName)) { |
||||
|
errors.toCol = colError |
||||
|
} |
||||
|
return errors |
||||
|
} |
||||
|
|
||||
|
$: tableOptions = plusTables.map(table => ({ |
||||
|
label: table.name, |
||||
|
value: table._id, |
||||
|
})) |
||||
|
$: fromTable = plusTables.find(table => table._id === toRelationship?.tableId) |
||||
|
$: toTable = plusTables.find(table => table._id === fromRelationship?.tableId) |
||||
|
$: through = plusTables.find(table => table._id === fromRelationship?.through) |
||||
|
$: errors = checkForErrors( |
||||
|
fromTable, |
||||
|
toTable, |
||||
|
through, |
||||
|
fromRelationship, |
||||
|
toRelationship |
||||
|
) |
||||
|
$: valid = |
||||
|
Object.keys(errors).length === 0 && Object.keys($touched).length !== 0 |
||||
|
$: linkTable = through || toTable |
||||
|
$: relationshipTypes = [ |
||||
|
{ |
||||
|
label: "Many", |
||||
|
value: RelationshipTypes.MANY_TO_MANY, |
||||
|
}, |
||||
|
{ |
||||
|
label: "One", |
||||
|
value: RelationshipTypes.MANY_TO_ONE, |
||||
|
}, |
||||
|
] |
||||
|
$: updateRelationshipType(fromRelationship?.relationshipType) |
||||
|
|
||||
|
function updateRelationshipType(fromType) { |
||||
|
if (fromType === RelationshipTypes.MANY_TO_MANY) { |
||||
|
toRelationship.relationshipType = RelationshipTypes.MANY_TO_MANY |
||||
|
} else { |
||||
|
toRelationship.relationshipType = RelationshipTypes.MANY_TO_ONE |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function buildRelationships() { |
||||
|
// if any to many only need to check from |
||||
|
const manyToMany = |
||||
|
fromRelationship.relationshipType === RelationshipTypes.MANY_TO_MANY |
||||
|
// main is simply used to know this is the side the user configured it from |
||||
|
const id = uuid() |
||||
|
if (!manyToMany) { |
||||
|
delete fromRelationship.through |
||||
|
delete toRelationship.through |
||||
|
} |
||||
|
let relateFrom = { |
||||
|
...fromRelationship, |
||||
|
type: "link", |
||||
|
main: true, |
||||
|
_id: id, |
||||
|
} |
||||
|
let relateTo = { |
||||
|
...toRelationship, |
||||
|
type: "link", |
||||
|
_id: id, |
||||
|
} |
||||
|
|
||||
|
// [0] is because we don't support composite keys for relationships right now |
||||
|
if (manyToMany) { |
||||
|
relateFrom = { |
||||
|
...relateFrom, |
||||
|
through: through._id, |
||||
|
fieldName: toTable.primary[0], |
||||
|
} |
||||
|
relateTo = { |
||||
|
...relateTo, |
||||
|
through: through._id, |
||||
|
fieldName: fromTable.primary[0], |
||||
|
} |
||||
|
} else { |
||||
|
relateFrom = { |
||||
|
...relateFrom, |
||||
|
foreignKey: relateFrom.fieldName, |
||||
|
fieldName: fromTable.primary[0], |
||||
|
} |
||||
|
relateTo = { |
||||
|
...relateTo, |
||||
|
relationshipType: RelationshipTypes.ONE_TO_MANY, |
||||
|
foreignKey: relateFrom.fieldName, |
||||
|
fieldName: fromTable.primary[0], |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fromRelationship = relateFrom |
||||
|
toRelationship = relateTo |
||||
|
} |
||||
|
|
||||
|
// save the relationship on to the datasource |
||||
|
async function saveRelationship() { |
||||
|
buildRelationships() |
||||
|
// source of relationship |
||||
|
datasource.entities[fromTable.name].schema[fromRelationship.name] = |
||||
|
fromRelationship |
||||
|
// save other side of relationship in the other schema |
||||
|
datasource.entities[toTable.name].schema[toRelationship.name] = |
||||
|
toRelationship |
||||
|
|
||||
|
// If relationship has been renamed |
||||
|
if (originalFromName !== fromRelationship.name) { |
||||
|
delete datasource.entities[fromTable.name].schema[originalFromName] |
||||
|
} |
||||
|
if (originalToName !== toRelationship.name) { |
||||
|
delete datasource.entities[toTable.name].schema[originalToName] |
||||
|
} |
||||
|
|
||||
|
await save() |
||||
|
await tables.fetch() |
||||
|
} |
||||
|
|
||||
|
async function deleteRelationship() { |
||||
|
delete datasource.entities[fromTable.name].schema[fromRelationship.name] |
||||
|
delete datasource.entities[toTable.name].schema[toRelationship.name] |
||||
|
await save() |
||||
|
await tables.fetch() |
||||
|
close() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
title="Create Relationship" |
||||
|
confirmText="Save" |
||||
|
onConfirm={saveRelationship} |
||||
|
disabled={!valid} |
||||
|
> |
||||
|
<Select |
||||
|
label="Relationship type" |
||||
|
options={relationshipTypes} |
||||
|
bind:value={fromRelationship.relationshipType} |
||||
|
/> |
||||
|
<div class="headings"> |
||||
|
<Detail>Tables</Detail> |
||||
|
</div> |
||||
|
<Select |
||||
|
label="Select from table" |
||||
|
options={tableOptions} |
||||
|
on:change={() => ($touched.from = true)} |
||||
|
bind:error={errors.from} |
||||
|
bind:value={toRelationship.tableId} |
||||
|
/> |
||||
|
<Select |
||||
|
label={"Select to table"} |
||||
|
options={tableOptions} |
||||
|
on:change={() => ($touched.to = true)} |
||||
|
bind:error={errors.to} |
||||
|
bind:value={fromRelationship.tableId} |
||||
|
/> |
||||
|
{#if fromRelationship?.relationshipType === RelationshipTypes.MANY_TO_MANY} |
||||
|
<Select |
||||
|
label={"Through"} |
||||
|
options={tableOptions} |
||||
|
on:change={() => ($touched.through = true)} |
||||
|
bind:error={errors.through} |
||||
|
bind:value={fromRelationship.through} |
||||
|
/> |
||||
|
{:else if fromRelationship?.relationshipType && toTable} |
||||
|
<Select |
||||
|
label={`Foreign Key (${toTable?.name})`} |
||||
|
options={Object.keys(toTable?.schema).filter( |
||||
|
field => toTable?.primary.indexOf(field) === -1 |
||||
|
)} |
||||
|
on:change={() => ($touched.foreign = true)} |
||||
|
bind:error={errors.foreign} |
||||
|
bind:value={fromRelationship.fieldName} |
||||
|
/> |
||||
|
{/if} |
||||
|
<div class="headings"> |
||||
|
<Detail>Column names</Detail> |
||||
|
</div> |
||||
|
<Body> |
||||
|
Budibase manages SQL relationships as a new column in the table, please |
||||
|
provide a name for these columns. |
||||
|
</Body> |
||||
|
<Input |
||||
|
on:blur={() => ($touched.fromCol = true)} |
||||
|
bind:error={errors.fromCol} |
||||
|
label="From table column" |
||||
|
bind:value={fromRelationship.name} |
||||
|
/> |
||||
|
<Input |
||||
|
on:blur={() => ($touched.toCol = true)} |
||||
|
bind:error={errors.toCol} |
||||
|
label="To table column" |
||||
|
bind:value={toRelationship.name} |
||||
|
/> |
||||
|
<div slot="footer"> |
||||
|
{#if originalFromName != null} |
||||
|
<Button warning text on:click={deleteRelationship}>Delete</Button> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</ModalContent> |
||||
|
|
||||
|
<style> |
||||
|
.headings { |
||||
|
margin-top: var(--spacing-s); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,21 @@ |
|||||
|
<script> |
||||
|
import { Menu, Icon, MenuSection, MenuItem } from "@budibase/bbui" |
||||
|
|
||||
|
export let heading |
||||
|
export let tables |
||||
|
export let selected = false |
||||
|
export let select |
||||
|
</script> |
||||
|
|
||||
|
<Menu> |
||||
|
<MenuSection {heading}> |
||||
|
{#each tables as table} |
||||
|
<MenuItem noClose icon="Table" on:click={() => select(table)}> |
||||
|
{table.name} |
||||
|
{#if selected} |
||||
|
<Icon size="S" name="Checkmark" /> |
||||
|
{/if} |
||||
|
</MenuItem> |
||||
|
{/each} |
||||
|
</MenuSection> |
||||
|
</Menu> |
||||
@ -0,0 +1,43 @@ |
|||||
|
<script> |
||||
|
import { ModalContent, Select, Body } from "@budibase/bbui" |
||||
|
import { tables } from "stores/backend" |
||||
|
|
||||
|
export let datasource |
||||
|
export let plusTables |
||||
|
export let save |
||||
|
|
||||
|
async function saveDisplayColumns() { |
||||
|
// be explicit about copying over |
||||
|
for (let table of plusTables) { |
||||
|
datasource.entities[table.name].primaryDisplay = table.primaryDisplay |
||||
|
} |
||||
|
save() |
||||
|
await tables.fetch() |
||||
|
} |
||||
|
|
||||
|
function getColumnOptions(table) { |
||||
|
if (!table || !table.schema) { |
||||
|
return [] |
||||
|
} |
||||
|
return Object.entries(table.schema) |
||||
|
.filter(field => field[1].type !== "link") |
||||
|
.map(([fieldName]) => fieldName) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
title="Edit display columns" |
||||
|
confirmText="Save" |
||||
|
onConfirm={saveDisplayColumns} |
||||
|
> |
||||
|
<Body |
||||
|
>Select the columns that will be shown when displaying relationships.</Body |
||||
|
> |
||||
|
{#each plusTables as table} |
||||
|
<Select |
||||
|
label={table.name} |
||||
|
options={getColumnOptions(table)} |
||||
|
bind:value={table.primaryDisplay} |
||||
|
/> |
||||
|
{/each} |
||||
|
</ModalContent> |
||||
File diff suppressed because it is too large
@ -0,0 +1,27 @@ |
|||||
|
import * as API from "../api" |
||||
|
import { get, writable } from "svelte/store" |
||||
|
|
||||
|
const createAppStore = () => { |
||||
|
const store = writable({}) |
||||
|
|
||||
|
// Fetches the app definition including screens, layouts and theme
|
||||
|
const fetchAppDefinition = async () => { |
||||
|
const appDefinition = await API.fetchAppPackage(get(store).appId) |
||||
|
store.set(appDefinition) |
||||
|
} |
||||
|
|
||||
|
// Sets the initial app ID
|
||||
|
const setAppID = id => { |
||||
|
store.update(state => { |
||||
|
state.appId = id |
||||
|
return state |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: store.subscribe, |
||||
|
actions: { setAppID, fetchAppDefinition }, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const appStore = createAppStore() |
||||
@ -1,7 +1,7 @@ |
|||||
import { routeStore } from "./routes" |
import { routeStore } from "./routes" |
||||
import { screenStore } from "./screens" |
import { appStore } from "./app" |
||||
|
|
||||
export async function initialise() { |
export async function initialise() { |
||||
await routeStore.actions.fetchRoutes() |
await routeStore.actions.fetchRoutes() |
||||
await screenStore.actions.fetchScreens() |
await appStore.actions.fetchAppDefinition() |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,6 @@ |
|||||
|
{ |
||||
|
"watch": ["src"], |
||||
|
"ext": "js,ts,json", |
||||
|
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
||||
|
"exec": "ts-node src/index.ts" |
||||
|
} |
||||
@ -1,9 +1,22 @@ |
|||||
CREATE DATABASE IF NOT EXISTS main; |
CREATE DATABASE IF NOT EXISTS main; |
||||
USE main; |
USE main; |
||||
CREATE TABLE Persons ( |
CREATE TABLE Persons ( |
||||
PersonID int NOT NULL PRIMARY KEY, |
PersonID int NOT NULL AUTO_INCREMENT, |
||||
LastName varchar(255), |
LastName varchar(255), |
||||
FirstName varchar(255), |
FirstName varchar(255), |
||||
Address varchar(255), |
Address varchar(255), |
||||
City varchar(255) |
City varchar(255), |
||||
|
PRIMARY KEY (PersonID) |
||||
); |
); |
||||
|
CREATE TABLE Tasks ( |
||||
|
TaskID int NOT NULL AUTO_INCREMENT, |
||||
|
PersonID INT, |
||||
|
TaskName varchar(255), |
||||
|
PRIMARY KEY (TaskID), |
||||
|
CONSTRAINT fkPersons |
||||
|
FOREIGN KEY(PersonID) |
||||
|
REFERENCES Persons(PersonID) |
||||
|
); |
||||
|
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast'); |
||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'assembling'); |
||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'processing'); |
||||
|
|||||
@ -0,0 +1,3 @@ |
|||||
|
#!/bin/bash |
||||
|
docker-compose down |
||||
|
docker volume prune -f |
||||
@ -1,17 +1,42 @@ |
|||||
SELECT 'CREATE DATABASE main' |
SELECT 'CREATE DATABASE main' |
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'main')\gexec |
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'main')\gexec |
||||
CREATE TABLE Persons ( |
CREATE TABLE Persons ( |
||||
PersonID INT NOT NULL PRIMARY KEY, |
PersonID SERIAL PRIMARY KEY, |
||||
LastName varchar(255), |
LastName varchar(255), |
||||
FirstName varchar(255), |
FirstName varchar(255), |
||||
Address varchar(255), |
Address varchar(255), |
||||
City varchar(255) |
City varchar(255) DEFAULT 'Belfast' |
||||
); |
); |
||||
CREATE TABLE Tasks ( |
CREATE TABLE Tasks ( |
||||
TaskID INT NOT NULL PRIMARY KEY, |
TaskID SERIAL PRIMARY KEY, |
||||
PersonID INT, |
PersonID INT, |
||||
TaskName varchar(255), |
TaskName varchar(255), |
||||
CONSTRAINT fkPersons |
CONSTRAINT fkPersons |
||||
FOREIGN KEY(PersonID) |
FOREIGN KEY(PersonID) |
||||
REFERENCES Persons(PersonID) |
REFERENCES Persons(PersonID) |
||||
); |
); |
||||
|
CREATE TABLE Products ( |
||||
|
ProductID SERIAL PRIMARY KEY, |
||||
|
ProductName varchar(255) |
||||
|
); |
||||
|
CREATE TABLE Products_Tasks ( |
||||
|
ProductID INT NOT NULL, |
||||
|
TaskID INT NOT NULL, |
||||
|
CONSTRAINT fkProducts |
||||
|
FOREIGN KEY(ProductID) |
||||
|
REFERENCES Products(ProductID), |
||||
|
CONSTRAINT fkTasks |
||||
|
FOREIGN KEY(TaskID) |
||||
|
REFERENCES Tasks(TaskID), |
||||
|
PRIMARY KEY (ProductID, TaskID) |
||||
|
); |
||||
|
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast'); |
||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'assembling'); |
||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'processing'); |
||||
|
INSERT INTO Products (ProductName) VALUES ('Computers'); |
||||
|
INSERT INTO Products (ProductName) VALUES ('Laptops'); |
||||
|
INSERT INTO Products (ProductName) VALUES ('Chairs'); |
||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 1); |
||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (2, 1); |
||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (3, 1); |
||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 2); |
||||
|
|||||
@ -0,0 +1,3 @@ |
|||||
|
#!/bin/bash |
||||
|
docker-compose down |
||||
|
docker volume prune -f |
||||
@ -0,0 +1,525 @@ |
|||||
|
import { |
||||
|
Operation, |
||||
|
SearchFilters, |
||||
|
SortJson, |
||||
|
PaginationJson, |
||||
|
RelationshipsJson, |
||||
|
} from "../../../definitions/datasource" |
||||
|
import { |
||||
|
Row, |
||||
|
Table, |
||||
|
FieldSchema, |
||||
|
Datasource, |
||||
|
} from "../../../definitions/common" |
||||
|
import { |
||||
|
breakRowIdField, |
||||
|
generateRowIdField, |
||||
|
} from "../../../integrations/utils" |
||||
|
|
||||
|
interface ManyRelationship { |
||||
|
tableId?: string |
||||
|
id?: string |
||||
|
isUpdate?: boolean |
||||
|
[key: string]: any |
||||
|
} |
||||
|
|
||||
|
interface RunConfig { |
||||
|
id: string |
||||
|
row: Row |
||||
|
filters: SearchFilters |
||||
|
sort: SortJson |
||||
|
paginate: PaginationJson |
||||
|
} |
||||
|
|
||||
|
module External { |
||||
|
const { makeExternalQuery } = require("./utils") |
||||
|
const { DataSourceOperation, FieldTypes } = require("../../../constants") |
||||
|
const { breakExternalTableId, isSQL } = require("../../../integrations/utils") |
||||
|
const { processObjectSync } = require("@budibase/string-templates") |
||||
|
const { cloneDeep } = require("lodash/fp") |
||||
|
const { isEqual } = require("lodash") |
||||
|
const CouchDB = require("../../../db") |
||||
|
|
||||
|
function buildFilters( |
||||
|
id: string | undefined, |
||||
|
filters: SearchFilters, |
||||
|
table: Table |
||||
|
) { |
||||
|
const primary = table.primary |
||||
|
// if passed in array need to copy for shifting etc
|
||||
|
let idCopy = cloneDeep(id) |
||||
|
if (filters) { |
||||
|
// need to map over the filters and make sure the _id field isn't present
|
||||
|
for (let filter of Object.values(filters)) { |
||||
|
if (filter._id && primary) { |
||||
|
const parts = breakRowIdField(filter._id) |
||||
|
for (let field of primary) { |
||||
|
filter[field] = parts.shift() |
||||
|
} |
||||
|
} |
||||
|
// make sure this field doesn't exist on any filter
|
||||
|
delete filter._id |
||||
|
} |
||||
|
} |
||||
|
// there is no id, just use the user provided filters
|
||||
|
if (!idCopy || !table) { |
||||
|
return filters |
||||
|
} |
||||
|
// if used as URL parameter it will have been joined
|
||||
|
if (!Array.isArray(idCopy)) { |
||||
|
idCopy = breakRowIdField(idCopy) |
||||
|
} |
||||
|
const equal: any = {} |
||||
|
if (primary && idCopy) { |
||||
|
for (let field of primary) { |
||||
|
// work through the ID and get the parts
|
||||
|
equal[field] = idCopy.shift() |
||||
|
} |
||||
|
} |
||||
|
return { |
||||
|
equal, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function generateIdForRow(row: Row, table: Table): string { |
||||
|
const primary = table.primary |
||||
|
if (!row || !primary) { |
||||
|
return "" |
||||
|
} |
||||
|
// build id array
|
||||
|
let idParts = [] |
||||
|
for (let field of primary) { |
||||
|
if (row[field]) { |
||||
|
idParts.push(row[field]) |
||||
|
} |
||||
|
} |
||||
|
if (idParts.length === 0) { |
||||
|
return "" |
||||
|
} |
||||
|
return generateRowIdField(idParts) |
||||
|
} |
||||
|
|
||||
|
function getEndpoint(tableId: string | undefined, operation: string) { |
||||
|
if (!tableId) { |
||||
|
return {} |
||||
|
} |
||||
|
const { datasourceId, tableName } = breakExternalTableId(tableId) |
||||
|
return { |
||||
|
datasourceId, |
||||
|
entityId: tableName, |
||||
|
operation, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function basicProcessing(row: Row, table: Table) { |
||||
|
const thisRow: { [key: string]: any } = {} |
||||
|
// filter the row down to what is actually the row (not joined)
|
||||
|
for (let fieldName of Object.keys(table.schema)) { |
||||
|
thisRow[fieldName] = row[fieldName] |
||||
|
} |
||||
|
thisRow._id = generateIdForRow(row, table) |
||||
|
thisRow.tableId = table._id |
||||
|
thisRow._rev = "rev" |
||||
|
return thisRow |
||||
|
} |
||||
|
|
||||
|
function isMany(field: FieldSchema) { |
||||
|
return ( |
||||
|
field.relationshipType && field.relationshipType.split("-")[0] === "many" |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
class ExternalRequest { |
||||
|
private readonly appId: string |
||||
|
private operation: Operation |
||||
|
private tableId: string |
||||
|
private datasource: Datasource |
||||
|
private tables: { [key: string]: Table } = {} |
||||
|
|
||||
|
constructor( |
||||
|
appId: string, |
||||
|
operation: Operation, |
||||
|
tableId: string, |
||||
|
datasource: Datasource |
||||
|
) { |
||||
|
this.appId = appId |
||||
|
this.operation = operation |
||||
|
this.tableId = tableId |
||||
|
this.datasource = datasource |
||||
|
if (datasource && datasource.entities) { |
||||
|
this.tables = datasource.entities |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
inputProcessing(row: Row, table: Table) { |
||||
|
if (!row) { |
||||
|
return { row, manyRelationships: [] } |
||||
|
} |
||||
|
// we don't really support composite keys for relationships, this is why [0] is used
|
||||
|
// @ts-ignore
|
||||
|
const tablePrimary: string = table.primary[0] |
||||
|
let newRow: Row = {}, |
||||
|
manyRelationships: ManyRelationship[] = [] |
||||
|
for (let [key, field] of Object.entries(table.schema)) { |
||||
|
// if set already, or not set just skip it
|
||||
|
if (!row[key] || newRow[key] || field.autocolumn) { |
||||
|
continue |
||||
|
} |
||||
|
// if its not a link then just copy it over
|
||||
|
if (field.type !== FieldTypes.LINK) { |
||||
|
newRow[key] = row[key] |
||||
|
continue |
||||
|
} |
||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId) |
||||
|
// table has to exist for many to many
|
||||
|
if (!this.tables[linkTableName]) { |
||||
|
continue |
||||
|
} |
||||
|
const linkTable = this.tables[linkTableName] |
||||
|
// @ts-ignore
|
||||
|
const linkTablePrimary = linkTable.primary[0] |
||||
|
if (!isMany(field)) { |
||||
|
newRow[field.foreignKey || linkTablePrimary] = breakRowIdField( |
||||
|
row[key][0] |
||||
|
)[0] |
||||
|
} else { |
||||
|
// we're not inserting a doc, will be a bunch of update calls
|
||||
|
const isUpdate = !field.through |
||||
|
const thisKey: string = isUpdate ? "id" : linkTablePrimary |
||||
|
// @ts-ignore
|
||||
|
const otherKey: string = isUpdate ? field.foreignKey : tablePrimary |
||||
|
row[key].map((relationship: any) => { |
||||
|
// we don't really support composite keys for relationships, this is why [0] is used
|
||||
|
manyRelationships.push({ |
||||
|
tableId: field.through || field.tableId, |
||||
|
isUpdate, |
||||
|
[thisKey]: breakRowIdField(relationship)[0], |
||||
|
// leave the ID for enrichment later
|
||||
|
[otherKey]: `{{ literal ${tablePrimary} }}`, |
||||
|
}) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
// we return the relationships that may need to be created in the through table
|
||||
|
// we do this so that if the ID is generated by the DB it can be inserted
|
||||
|
// after the fact
|
||||
|
return { row: newRow, manyRelationships } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This iterates through the returned rows and works out what elements of the rows |
||||
|
* actually match up to another row (based on primary keys) - this is pretty specific |
||||
|
* to SQL and the way that SQL relationships are returned based on joins. |
||||
|
*/ |
||||
|
updateRelationshipColumns( |
||||
|
row: Row, |
||||
|
rows: { [key: string]: Row }, |
||||
|
relationships: RelationshipsJson[] |
||||
|
) { |
||||
|
const columns: { [key: string]: any } = {} |
||||
|
for (let relationship of relationships) { |
||||
|
const linkedTable = this.tables[relationship.tableName] |
||||
|
if (!linkedTable) { |
||||
|
continue |
||||
|
} |
||||
|
let linked = basicProcessing(row, linkedTable) |
||||
|
if (!linked._id) { |
||||
|
continue |
||||
|
} |
||||
|
// if not returning full docs then get the minimal links out
|
||||
|
const display = linkedTable.primaryDisplay |
||||
|
linked = { |
||||
|
primaryDisplay: display ? linked[display] : undefined, |
||||
|
_id: linked._id, |
||||
|
} |
||||
|
columns[relationship.column] = linked |
||||
|
} |
||||
|
for (let [column, related] of Object.entries(columns)) { |
||||
|
if (!row._id) { |
||||
|
continue |
||||
|
} |
||||
|
const rowId: string = row._id |
||||
|
if (!Array.isArray(rows[rowId][column])) { |
||||
|
rows[rowId][column] = [] |
||||
|
} |
||||
|
// make sure relationship hasn't been found already
|
||||
|
if ( |
||||
|
!rows[rowId][column].find( |
||||
|
(relation: Row) => relation._id === related._id |
||||
|
) |
||||
|
) { |
||||
|
rows[rowId][column].push(related) |
||||
|
} |
||||
|
} |
||||
|
return rows |
||||
|
} |
||||
|
|
||||
|
outputProcessing( |
||||
|
rows: Row[], |
||||
|
table: Table, |
||||
|
relationships: RelationshipsJson[] |
||||
|
) { |
||||
|
if (rows[0].read === true) { |
||||
|
return [] |
||||
|
} |
||||
|
let finalRows: { [key: string]: Row } = {} |
||||
|
for (let row of rows) { |
||||
|
const rowId = generateIdForRow(row, table) |
||||
|
row._id = rowId |
||||
|
// this is a relationship of some sort
|
||||
|
if (finalRows[rowId]) { |
||||
|
finalRows = this.updateRelationshipColumns( |
||||
|
row, |
||||
|
finalRows, |
||||
|
relationships |
||||
|
) |
||||
|
continue |
||||
|
} |
||||
|
const thisRow = basicProcessing(row, table) |
||||
|
finalRows[thisRow._id] = thisRow |
||||
|
// do this at end once its been added to the final rows
|
||||
|
finalRows = this.updateRelationshipColumns( |
||||
|
row, |
||||
|
finalRows, |
||||
|
relationships |
||||
|
) |
||||
|
} |
||||
|
return Object.values(finalRows) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gets the list of relationship JSON structures based on the columns in the table, |
||||
|
* this will be used by the underlying library to build whatever relationship mechanism |
||||
|
* it has (e.g. SQL joins). |
||||
|
*/ |
||||
|
buildRelationships(table: Table): RelationshipsJson[] { |
||||
|
const relationships = [] |
||||
|
for (let [fieldName, field] of Object.entries(table.schema)) { |
||||
|
if (field.type !== FieldTypes.LINK) { |
||||
|
continue |
||||
|
} |
||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId) |
||||
|
// no table to link to, this is not a valid relationships
|
||||
|
if (!this.tables[linkTableName]) { |
||||
|
continue |
||||
|
} |
||||
|
const linkTable = this.tables[linkTableName] |
||||
|
if (!table.primary || !linkTable.primary) { |
||||
|
continue |
||||
|
} |
||||
|
const definition = { |
||||
|
// if no foreign key specified then use the name of the field in other table
|
||||
|
from: field.foreignKey || table.primary[0], |
||||
|
to: field.fieldName, |
||||
|
tableName: linkTableName, |
||||
|
through: undefined, |
||||
|
// need to specify where to put this back into
|
||||
|
column: fieldName, |
||||
|
} |
||||
|
if (field.through) { |
||||
|
const { tableName: throughTableName } = breakExternalTableId( |
||||
|
field.through |
||||
|
) |
||||
|
definition.through = throughTableName |
||||
|
// don't support composite keys for relationships
|
||||
|
definition.from = table.primary[0] |
||||
|
definition.to = linkTable.primary[0] |
||||
|
} |
||||
|
relationships.push(definition) |
||||
|
} |
||||
|
return relationships |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This is a cached lookup, of relationship records, this is mainly for creating/deleting junction |
||||
|
* information. |
||||
|
*/ |
||||
|
async lookup( |
||||
|
row: Row, |
||||
|
relationship: ManyRelationship, |
||||
|
cache: { [key: string]: Row[] } = {} |
||||
|
) { |
||||
|
const { tableId, isUpdate, id, ...rest } = relationship |
||||
|
const { tableName } = breakExternalTableId(tableId) |
||||
|
const table = this.tables[tableName] |
||||
|
if (isUpdate) { |
||||
|
return { rows: [], table } |
||||
|
} |
||||
|
// if not updating need to make sure we have a list of all possible options
|
||||
|
let fullKey: string = tableId + "/", |
||||
|
rowKey: string = "" |
||||
|
for (let key of Object.keys(rest)) { |
||||
|
if (row[key]) { |
||||
|
fullKey += key |
||||
|
rowKey = key |
||||
|
} |
||||
|
} |
||||
|
if (cache[fullKey] == null) { |
||||
|
cache[fullKey] = await makeExternalQuery(this.appId, { |
||||
|
endpoint: getEndpoint(tableId, DataSourceOperation.READ), |
||||
|
filters: { |
||||
|
equal: { |
||||
|
[rowKey]: row[rowKey], |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
return { rows: cache[fullKey], table } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Once a row has been written we may need to update a many field, e.g. updating foreign keys |
||||
|
* in a bunch of rows in another table, or inserting/deleting rows from a junction table (many to many). |
||||
|
* This is quite a complex process and is handled by this function, there are a few things going on here: |
||||
|
* 1. If updating foreign keys its relatively simple, just create a filter for the row that needs updated |
||||
|
* and write the various components. |
||||
|
* 2. If junction table, then we lookup what exists already, write what doesn't exist, work out what |
||||
|
* isn't supposed to exist anymore and delete those. This is better than the usual method of delete them |
||||
|
* all and then re-create, as theres no chance of losing data (e.g. delete succeed, but write fail). |
||||
|
*/ |
||||
|
async handleManyRelationships(row: Row, relationships: ManyRelationship[]) { |
||||
|
const { appId } = this |
||||
|
if (relationships.length === 0) { |
||||
|
return |
||||
|
} |
||||
|
// if we're creating (in a through table) need to wipe the existing ones first
|
||||
|
const promises = [] |
||||
|
const cache: { [key: string]: Row[] } = {} |
||||
|
for (let relationship of relationships) { |
||||
|
const { tableId, isUpdate, id, ...rest } = relationship |
||||
|
const body = processObjectSync(rest, row) |
||||
|
const { table, rows } = await this.lookup(row, relationship, cache) |
||||
|
const found = rows.find(row => isEqual(body, row)) |
||||
|
const operation = isUpdate |
||||
|
? DataSourceOperation.UPDATE |
||||
|
: DataSourceOperation.CREATE |
||||
|
if (!found) { |
||||
|
promises.push( |
||||
|
makeExternalQuery(appId, { |
||||
|
endpoint: getEndpoint(tableId, operation), |
||||
|
// if we're doing many relationships then we're writing, only one response
|
||||
|
body, |
||||
|
filters: buildFilters(id, {}, table), |
||||
|
}) |
||||
|
) |
||||
|
} else { |
||||
|
// remove the relationship from the rows
|
||||
|
rows.splice(rows.indexOf(found), 1) |
||||
|
} |
||||
|
} |
||||
|
// finally if creating, cleanup any rows that aren't supposed to be here
|
||||
|
for (let [key, rows] of Object.entries(cache)) { |
||||
|
// @ts-ignore
|
||||
|
const tableId: string = key.split("/").shift() |
||||
|
const { tableName } = breakExternalTableId(tableId) |
||||
|
const table = this.tables[tableName] |
||||
|
for (let row of rows) { |
||||
|
promises.push( |
||||
|
makeExternalQuery(this.appId, { |
||||
|
endpoint: getEndpoint(tableId, DataSourceOperation.DELETE), |
||||
|
filters: buildFilters(generateIdForRow(row, table), {}, table), |
||||
|
}) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which |
||||
|
* you have column overlap in relationships, e.g. we join a few different tables and they all have the |
||||
|
* concept of an ID, but for some of them it will be null (if they say don't have a relationship). |
||||
|
* Creating the specific list of fields that we desire, and excluding the ones that are no use to us |
||||
|
* is more performant and has the added benefit of protecting against this scenario. |
||||
|
*/ |
||||
|
buildFields(table: Table) { |
||||
|
function extractNonLinkFieldNames(table: Table, existing: string[] = []) { |
||||
|
return Object.entries(table.schema) |
||||
|
.filter( |
||||
|
column => |
||||
|
column[1].type !== FieldTypes.LINK && |
||||
|
!existing.find((field: string) => field.includes(column[0])) |
||||
|
) |
||||
|
.map(column => `${table.name}.${column[0]}`) |
||||
|
} |
||||
|
let fields = extractNonLinkFieldNames(table) |
||||
|
for (let field of Object.values(table.schema)) { |
||||
|
if (field.type !== FieldTypes.LINK) { |
||||
|
continue |
||||
|
} |
||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId) |
||||
|
const linkTable = this.tables[linkTableName] |
||||
|
if (linkTable) { |
||||
|
const linkedFields = extractNonLinkFieldNames(linkTable, fields) |
||||
|
fields = fields.concat(linkedFields) |
||||
|
} |
||||
|
} |
||||
|
return fields |
||||
|
} |
||||
|
|
||||
|
async run({ id, row, filters, sort, paginate }: RunConfig) { |
||||
|
const { appId, operation, tableId } = this |
||||
|
let { datasourceId, tableName } = breakExternalTableId(tableId) |
||||
|
if (!this.datasource) { |
||||
|
const db = new CouchDB(appId) |
||||
|
this.datasource = await db.get(datasourceId) |
||||
|
if (!this.datasource || !this.datasource.entities) { |
||||
|
throw "No tables found, fetch tables before query." |
||||
|
} |
||||
|
this.tables = this.datasource.entities |
||||
|
} |
||||
|
const table = this.tables[tableName] |
||||
|
let isSql = isSQL(this.datasource) |
||||
|
if (!table) { |
||||
|
throw `Unable to process query, table "${tableName}" not defined.` |
||||
|
} |
||||
|
// clean up row on ingress using schema
|
||||
|
filters = buildFilters(id, filters, table) |
||||
|
const relationships = this.buildRelationships(table) |
||||
|
const processed = this.inputProcessing(row, table) |
||||
|
row = processed.row |
||||
|
if ( |
||||
|
operation === DataSourceOperation.DELETE && |
||||
|
(filters == null || Object.keys(filters).length === 0) |
||||
|
) { |
||||
|
throw "Deletion must be filtered" |
||||
|
} |
||||
|
let json = { |
||||
|
endpoint: { |
||||
|
datasourceId, |
||||
|
entityId: tableName, |
||||
|
operation, |
||||
|
}, |
||||
|
resource: { |
||||
|
// have to specify the fields to avoid column overlap (for SQL)
|
||||
|
fields: isSql ? this.buildFields(table) : [], |
||||
|
}, |
||||
|
filters, |
||||
|
sort, |
||||
|
paginate, |
||||
|
relationships, |
||||
|
body: row, |
||||
|
// pass an id filter into extra, purely for mysql/returning
|
||||
|
extra: { |
||||
|
idFilter: buildFilters(id || generateIdForRow(row, table), {}, table), |
||||
|
}, |
||||
|
} |
||||
|
// can't really use response right now
|
||||
|
const response = await makeExternalQuery(appId, json) |
||||
|
// handle many to many relationships now if we know the ID (could be auto increment)
|
||||
|
if (processed.manyRelationships) { |
||||
|
await this.handleManyRelationships( |
||||
|
response[0], |
||||
|
processed.manyRelationships |
||||
|
) |
||||
|
} |
||||
|
const output = this.outputProcessing(response, table, relationships) |
||||
|
// if reading it'll just be an array of rows, return whole thing
|
||||
|
return operation === DataSourceOperation.READ && Array.isArray(response) |
||||
|
? output |
||||
|
: { row: output[0], table } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = ExternalRequest |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
import { SourceNames } from "./datasource" |
||||
|
|
||||
|
interface Base { |
||||
|
_id?: string |
||||
|
_rev?: string |
||||
|
} |
||||
|
|
||||
|
export interface FieldSchema { |
||||
|
// TODO: replace with field types enum when done
|
||||
|
type: string |
||||
|
fieldName?: string |
||||
|
name: string |
||||
|
tableId?: string |
||||
|
relationshipType?: string |
||||
|
through?: string |
||||
|
foreignKey?: string |
||||
|
autocolumn?: boolean |
||||
|
constraints?: { |
||||
|
type?: string |
||||
|
email?: boolean |
||||
|
inclusion?: string[] |
||||
|
length?: { |
||||
|
minimum?: string | number |
||||
|
maximum?: string | number |
||||
|
} |
||||
|
presence?: boolean |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface TableSchema { |
||||
|
[key: string]: FieldSchema |
||||
|
} |
||||
|
|
||||
|
export interface Table extends Base { |
||||
|
type?: string |
||||
|
views?: {} |
||||
|
name?: string |
||||
|
primary?: string[] |
||||
|
schema: TableSchema |
||||
|
primaryDisplay?: string |
||||
|
sourceId?: string |
||||
|
} |
||||
|
|
||||
|
export interface Row extends Base { |
||||
|
type?: string |
||||
|
tableId?: string |
||||
|
[key: string]: any |
||||
|
} |
||||
|
|
||||
|
interface JsonSchemaField { |
||||
|
properties: { |
||||
|
[key: string]: { |
||||
|
type: string |
||||
|
title: string |
||||
|
customType?: string |
||||
|
} |
||||
|
} |
||||
|
required?: string[] |
||||
|
} |
||||
|
|
||||
|
export interface AutomationStep { |
||||
|
description: string |
||||
|
event?: string |
||||
|
icon: string |
||||
|
id: string |
||||
|
inputs: { |
||||
|
[key: string]: any |
||||
|
} |
||||
|
name: string |
||||
|
schema: { |
||||
|
inputs: JsonSchemaField |
||||
|
outputs: JsonSchemaField |
||||
|
} |
||||
|
stepId: string |
||||
|
tagline: string |
||||
|
type: string |
||||
|
} |
||||
|
|
||||
|
export interface Automation extends Base { |
||||
|
name: string |
||||
|
type: string |
||||
|
appId?: string |
||||
|
definition: { |
||||
|
steps: AutomationStep[] |
||||
|
trigger?: AutomationStep |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface Datasource extends Base { |
||||
|
type: string |
||||
|
name: string |
||||
|
source: SourceNames |
||||
|
// the config is defined by the schema
|
||||
|
config: { |
||||
|
[key: string]: string | number | boolean |
||||
|
} |
||||
|
plus: boolean |
||||
|
entities?: { |
||||
|
[key: string]: Table |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue