498 lines
14 KiB
Svelte
498 lines
14 KiB
Svelte
<script>
|
|
import {
|
|
RelationshipType,
|
|
PrettyRelationshipDefinitions,
|
|
} from "constants/backend"
|
|
import {
|
|
keepOpen,
|
|
Button,
|
|
Input,
|
|
ModalContent,
|
|
Select,
|
|
Detail,
|
|
Body,
|
|
Helpers,
|
|
} from "@budibase/bbui"
|
|
import { tables } from "stores/builder"
|
|
import { RelationshipErrorChecker } from "./relationshipErrors"
|
|
import { onMount } from "svelte"
|
|
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
|
|
|
export let save
|
|
export let datasource
|
|
export let plusTables = []
|
|
export let fromRelationship = {}
|
|
export let toRelationship = {}
|
|
export let selectedFromTable
|
|
export let close
|
|
|
|
let relationshipMap = {
|
|
[RelationshipType.MANY_TO_MANY]: {
|
|
part1: PrettyRelationshipDefinitions.MANY,
|
|
part2: PrettyRelationshipDefinitions.MANY,
|
|
},
|
|
[RelationshipType.MANY_TO_ONE]: {
|
|
part1: PrettyRelationshipDefinitions.MANY,
|
|
part2: PrettyRelationshipDefinitions.ONE,
|
|
},
|
|
[RelationshipType.ONE_TO_MANY]: {
|
|
part1: PrettyRelationshipDefinitions.ONE,
|
|
part2: PrettyRelationshipDefinitions.MANY,
|
|
},
|
|
}
|
|
$: relationshipOpts1 =
|
|
relationshipPart2 === PrettyRelationshipDefinitions.ONE
|
|
? [PrettyRelationshipDefinitions.MANY]
|
|
: Object.values(PrettyRelationshipDefinitions)
|
|
|
|
$: relationshipOpts2 =
|
|
relationshipPart1 === PrettyRelationshipDefinitions.ONE
|
|
? [PrettyRelationshipDefinitions.MANY]
|
|
: Object.values(PrettyRelationshipDefinitions)
|
|
|
|
let relationshipPart1 = PrettyRelationshipDefinitions.ONE
|
|
let relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
|
|
|
let originalFromColumnName = toRelationship.name,
|
|
originalToColumnName = fromRelationship.name
|
|
let originalFromTable = plusTables.find(
|
|
table => table._id === toRelationship?.tableId
|
|
)
|
|
let originalToTable = plusTables.find(
|
|
table => table._id === fromRelationship?.tableId
|
|
)
|
|
|
|
let tableOptions
|
|
let errorChecker = new RelationshipErrorChecker(
|
|
invalidThroughTable,
|
|
manyToManyRelationshipExistsFn
|
|
)
|
|
let errors = {}
|
|
let fromPrimary, fromForeign, fromColumn, toColumn
|
|
|
|
let throughId, throughToKey, throughFromKey
|
|
let relationshipType
|
|
let hasValidated = false
|
|
|
|
$: fromId = null
|
|
$: toId = null
|
|
|
|
$: tableOptions = plusTables.map(table => ({
|
|
label: table.name,
|
|
value: table._id,
|
|
name: table.name,
|
|
_id: table._id,
|
|
}))
|
|
|
|
$: {
|
|
// Determine the relationship type based on the selected values of both parts
|
|
relationshipType = Object.entries(relationshipMap).find(
|
|
([_, parts]) =>
|
|
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
|
|
)?.[0]
|
|
|
|
changed(() => {
|
|
hasValidated = false
|
|
})
|
|
}
|
|
|
|
$: valid =
|
|
getErrorCount(errors) === 0 &&
|
|
allRequiredAttributesSet(relationshipType) &&
|
|
fromId &&
|
|
toId
|
|
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
|
|
$: isManyToOne =
|
|
relationshipType === RelationshipType.MANY_TO_ONE ||
|
|
relationshipType === RelationshipType.ONE_TO_MANY
|
|
function getTable(id) {
|
|
return plusTables.find(table => table._id === id)
|
|
}
|
|
|
|
function invalidThroughTable() {
|
|
// need to know the foreign key columns to check error
|
|
if (!throughId || !throughToKey || !throughFromKey) {
|
|
return false
|
|
}
|
|
const throughTbl = plusTables.find(tbl => tbl._id === throughId)
|
|
const otherColumns = Object.values(throughTbl.schema).filter(
|
|
col => col.name !== throughFromKey && col.name !== throughToKey
|
|
)
|
|
for (let col of otherColumns) {
|
|
if (col.constraints?.presence && !col.autocolumn) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
function manyToManyRelationshipExistsFn() {
|
|
if (
|
|
originalFromTable &&
|
|
originalToTable &&
|
|
originalFromTable === getTable(fromId) &&
|
|
originalToTable === getTable(toId)
|
|
) {
|
|
return false
|
|
}
|
|
let fromThroughLinks = Object.values(
|
|
datasource.entities[getTable(fromId).name].schema
|
|
).filter(value => value.through)
|
|
let toThroughLinks = Object.values(
|
|
datasource.entities[getTable(toId).name].schema
|
|
).filter(value => value.through)
|
|
|
|
const matchAgainstUserInput = link =>
|
|
(link.throughTo === throughToKey &&
|
|
link.throughFrom === throughFromKey) ||
|
|
(link.throughTo === throughFromKey && link.throughFrom === throughToKey)
|
|
|
|
const allLinks = [...fromThroughLinks, ...toThroughLinks]
|
|
return !!allLinks.find(
|
|
link => link.through === throughId && matchAgainstUserInput(link)
|
|
)
|
|
}
|
|
|
|
function getErrorCount(errors) {
|
|
return Object.entries(errors).filter(entry => !!entry[1]).length
|
|
}
|
|
|
|
function allRequiredAttributesSet(relationshipType) {
|
|
const base = getTable(fromId) && getTable(toId) && fromColumn && toColumn
|
|
if (relationshipType === RelationshipType.MANY_TO_ONE) {
|
|
return base && fromPrimary && fromForeign
|
|
} else {
|
|
return base && getTable(throughId) && throughFromKey && throughToKey
|
|
}
|
|
}
|
|
|
|
function validate() {
|
|
if (!allRequiredAttributesSet(relationshipType) && !hasValidated) {
|
|
return
|
|
}
|
|
|
|
hasValidated = true
|
|
errorChecker.setType(relationshipType)
|
|
const fromTable = getTable(fromId),
|
|
toTable = getTable(toId),
|
|
throughTable = getTable(throughId)
|
|
errors = {
|
|
relationshipType: errorChecker.relationshipTypeSet(relationshipType),
|
|
fromTable:
|
|
errorChecker.tableSet(fromTable) ||
|
|
errorChecker.differentTables(fromId, toId, throughId),
|
|
toTable:
|
|
errorChecker.tableSet(toTable) ||
|
|
errorChecker.differentTables(toId, fromId, throughId),
|
|
throughTable:
|
|
errorChecker.throughTableSet(throughTable) ||
|
|
errorChecker.throughIsNullable() ||
|
|
errorChecker.differentTables(throughId, fromId, toId) ||
|
|
errorChecker.doesRelationshipExists(),
|
|
throughFromKey:
|
|
errorChecker.manyForeignKeySet(throughFromKey) ||
|
|
errorChecker.manyTypeMismatch(
|
|
fromTable,
|
|
throughTable,
|
|
fromTable.primary[0],
|
|
throughToKey
|
|
) ||
|
|
errorChecker.differentColumns(throughFromKey, throughToKey),
|
|
throughToKey:
|
|
errorChecker.manyForeignKeySet(throughToKey) ||
|
|
errorChecker.manyTypeMismatch(
|
|
toTable,
|
|
throughTable,
|
|
toTable.primary[0],
|
|
throughFromKey
|
|
),
|
|
fromForeign:
|
|
errorChecker.foreignKeySet(fromForeign) ||
|
|
errorChecker.typeMismatch(fromTable, toTable, fromPrimary, fromForeign),
|
|
fromPrimary: errorChecker.primaryKeySet(fromPrimary),
|
|
fromColumn: errorChecker.columnBeingUsed(
|
|
toTable,
|
|
fromColumn,
|
|
originalFromColumnName
|
|
),
|
|
toColumn: errorChecker.columnBeingUsed(
|
|
fromTable,
|
|
toColumn,
|
|
originalToColumnName
|
|
),
|
|
}
|
|
return getErrorCount(errors) === 0
|
|
}
|
|
|
|
function otherRelationshipType(type) {
|
|
if (type === RelationshipType.MANY_TO_ONE) {
|
|
return RelationshipType.ONE_TO_MANY
|
|
} else if (type === RelationshipType.ONE_TO_MANY) {
|
|
return RelationshipType.MANY_TO_ONE
|
|
} else if (type === RelationshipType.MANY_TO_MANY) {
|
|
return RelationshipType.MANY_TO_MANY
|
|
}
|
|
}
|
|
|
|
function buildRelationships() {
|
|
const id = Helpers.uuid()
|
|
//Map temporary variables
|
|
let relateFrom = {
|
|
...fromRelationship,
|
|
tableId: toId,
|
|
name: toColumn,
|
|
relationshipType,
|
|
fieldName: fromForeign,
|
|
through: throughId,
|
|
throughFrom: throughFromKey,
|
|
throughTo: throughToKey,
|
|
type: "link",
|
|
main: true,
|
|
_id: id,
|
|
}
|
|
let relateTo = (toRelationship = {
|
|
...toRelationship,
|
|
tableId: fromId,
|
|
name: fromColumn,
|
|
relationshipType: otherRelationshipType(relationshipType),
|
|
through: throughId,
|
|
type: "link",
|
|
_id: id,
|
|
})
|
|
|
|
// if any to many only need to check from
|
|
const manyToMany =
|
|
relateFrom.relationshipType === RelationshipType.MANY_TO_MANY
|
|
|
|
if (!manyToMany) {
|
|
delete relateFrom.through
|
|
delete relateTo.through
|
|
}
|
|
|
|
// [0] is because we don't support composite keys for relationships right now
|
|
if (manyToMany) {
|
|
relateFrom = {
|
|
...relateFrom,
|
|
through: getTable(throughId)._id,
|
|
fieldName: getTable(toId).primary[0],
|
|
}
|
|
relateTo = {
|
|
...relateTo,
|
|
through: getTable(throughId)._id,
|
|
fieldName: getTable(fromId).primary[0],
|
|
throughFrom: relateFrom.throughTo,
|
|
throughTo: relateFrom.throughFrom,
|
|
}
|
|
} else {
|
|
// the relateFrom.fieldName should remain the same, as it is the foreignKey in the other
|
|
// table, this is due to the way that budibase represents relationships, the fieldName in a
|
|
// link column schema is the column linked to (FK in this case). The foreignKey column is
|
|
// essentially what is linked to in the from table, this is unique to SQL as this isn't a feature
|
|
// of Budibase internal tables.
|
|
// Essentially this means the fieldName is what we are linking to in the other table, and the
|
|
// foreignKey is what is linking out of the current table.
|
|
relateFrom = {
|
|
...relateFrom,
|
|
foreignKey: fromPrimary,
|
|
}
|
|
relateTo = {
|
|
...relateTo,
|
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
|
foreignKey: relateFrom.fieldName,
|
|
fieldName: fromPrimary,
|
|
}
|
|
}
|
|
|
|
fromRelationship = relateFrom
|
|
toRelationship = relateTo
|
|
}
|
|
|
|
function removeExistingRelationship() {
|
|
if (originalFromTable && originalFromColumnName) {
|
|
delete datasource.entities[originalFromTable.name].schema[
|
|
originalToColumnName
|
|
]
|
|
}
|
|
if (originalToTable && originalToColumnName) {
|
|
delete datasource.entities[originalToTable.name].schema[
|
|
originalFromColumnName
|
|
]
|
|
}
|
|
}
|
|
|
|
async function saveRelationship() {
|
|
if (!validate()) {
|
|
return keepOpen
|
|
}
|
|
buildRelationships()
|
|
removeExistingRelationship()
|
|
|
|
// source of relationship
|
|
datasource.entities[getTable(fromId).name].schema[fromRelationship.name] =
|
|
fromRelationship
|
|
// save other side of relationship in the other schema
|
|
datasource.entities[getTable(toId).name].schema[toRelationship.name] =
|
|
toRelationship
|
|
|
|
await save({ action: "saved" })
|
|
}
|
|
async function deleteRelationship() {
|
|
removeExistingRelationship()
|
|
await save({ action: "deleted" })
|
|
await tables.fetch()
|
|
close()
|
|
}
|
|
|
|
function changed(fn) {
|
|
if (typeof fn === "function") {
|
|
fn()
|
|
}
|
|
validate()
|
|
}
|
|
|
|
onMount(() => {
|
|
if (fromRelationship) {
|
|
fromPrimary = fromRelationship.foreignKey
|
|
toId = fromRelationship.tableId
|
|
throughId = fromRelationship.through
|
|
throughFromKey = fromRelationship.throughFrom
|
|
throughToKey = fromRelationship.throughTo
|
|
toColumn = fromRelationship.name
|
|
}
|
|
if (toRelationship) {
|
|
fromForeign = toRelationship.foreignKey
|
|
fromId = toRelationship.tableId
|
|
fromColumn = toRelationship.name
|
|
}
|
|
relationshipType =
|
|
fromRelationship.relationshipType || RelationshipType.MANY_TO_ONE
|
|
if (selectedFromTable) {
|
|
fromId = selectedFromTable._id
|
|
fromColumn = selectedFromTable.name
|
|
fromPrimary = selectedFromTable?.primary[0] || null
|
|
}
|
|
if (relationshipType === RelationshipType.MANY_TO_MANY) {
|
|
relationshipPart1 = PrettyRelationshipDefinitions.MANY
|
|
relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
|
} else if (relationshipType === RelationshipType.MANY_TO_ONE) {
|
|
relationshipPart1 = PrettyRelationshipDefinitions.ONE
|
|
relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
|
} else {
|
|
relationshipPart1 = PrettyRelationshipDefinitions.MANY
|
|
relationshipPart2 = PrettyRelationshipDefinitions.ONE
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<ModalContent
|
|
title="Define Relationship"
|
|
confirmText="Save"
|
|
onConfirm={saveRelationship}
|
|
disabled={!valid}
|
|
size="L"
|
|
>
|
|
<div class="headings">
|
|
<Detail>Tables</Detail>
|
|
</div>
|
|
|
|
<RelationshipSelector
|
|
bind:relationshipPart1
|
|
bind:relationshipPart2
|
|
bind:relationshipTableIdPrimary={fromId}
|
|
bind:relationshipTableIdSecondary={toId}
|
|
{relationshipOpts1}
|
|
{relationshipOpts2}
|
|
{tableOptions}
|
|
{errors}
|
|
primaryDisabled={selectedFromTable}
|
|
primaryTableChanged={e =>
|
|
changed(() => {
|
|
const table = plusTables.find(tbl => tbl._id === e.detail)
|
|
fromColumn = table?.name || ""
|
|
fromPrimary = table?.primary?.[0]
|
|
})}
|
|
secondaryTableChanged={e =>
|
|
changed(() => {
|
|
const table = plusTables.find(tbl => tbl._id === e.detail)
|
|
toColumn = table.name || ""
|
|
fromForeign = null
|
|
})}
|
|
/>
|
|
|
|
{#if isManyToOne && fromId}
|
|
<Select
|
|
label={`Primary Key (${getTable(fromId).name})`}
|
|
options={Object.keys(getTable(fromId).schema)}
|
|
bind:value={fromPrimary}
|
|
bind:error={errors.fromPrimary}
|
|
on:change={changed}
|
|
/>
|
|
{/if}
|
|
{#if isManyToMany}
|
|
<Select
|
|
label={"Through"}
|
|
options={tableOptions}
|
|
bind:value={throughId}
|
|
bind:error={errors.throughTable}
|
|
on:change={() =>
|
|
changed(() => {
|
|
throughToKey = null
|
|
throughFromKey = null
|
|
})}
|
|
/>
|
|
{#if fromId && toId && throughId}
|
|
<Select
|
|
label={`Foreign Key (${getTable(fromId)?.name})`}
|
|
options={Object.keys(getTable(throughId)?.schema)}
|
|
bind:value={throughToKey}
|
|
bind:error={errors.throughToKey}
|
|
on:change={changed}
|
|
/>
|
|
<Select
|
|
label={`Foreign Key (${getTable(toId)?.name})`}
|
|
options={Object.keys(getTable(throughId)?.schema)}
|
|
bind:value={throughFromKey}
|
|
bind:error={errors.throughFromKey}
|
|
on:change={changed}
|
|
/>
|
|
{/if}
|
|
{:else if isManyToOne && toId}
|
|
<Select
|
|
label={`Foreign Key (${getTable(toId)?.name})`}
|
|
options={Object.keys(getTable(toId)?.schema)}
|
|
bind:value={fromForeign}
|
|
bind:error={errors.fromForeign}
|
|
on:change={changed}
|
|
/>
|
|
{/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
|
|
label="From table column"
|
|
bind:value={fromColumn}
|
|
bind:error={errors.fromColumn}
|
|
on:change={changed}
|
|
/>
|
|
<Input
|
|
label="To table column"
|
|
bind:value={toColumn}
|
|
bind:error={errors.toColumn}
|
|
on:change={changed}
|
|
/>
|
|
<div slot="footer">
|
|
{#if originalFromColumnName != null}
|
|
<Button warning text on:click={deleteRelationship}>Delete</Button>
|
|
{/if}
|
|
</div>
|
|
</ModalContent>
|
|
|
|
<style>
|
|
.headings {
|
|
margin-top: var(--spacing-s);
|
|
}
|
|
</style>
|