forked from tsai/budibase
136 changed files with 4423 additions and 1080 deletions
@ -0,0 +1,54 @@ |
|||
<script> |
|||
import { ActionButton, Modal, notifications } from "@budibase/bbui" |
|||
import CreateEditRelationship from "../../Datasources/CreateEditRelationship.svelte" |
|||
import { datasources, tables } from "../../../../stores/backend" |
|||
import { createEventDispatcher } from "svelte" |
|||
|
|||
export let table |
|||
const dispatch = createEventDispatcher() |
|||
|
|||
$: plusTables = datasource?.plus |
|||
? Object.values(datasource?.entities || {}) |
|||
: [] |
|||
$: datasource = $datasources.list.find( |
|||
source => source._id === table?.sourceId |
|||
) |
|||
|
|||
let modal |
|||
|
|||
async function saveRelationship() { |
|||
try { |
|||
// Create datasource |
|||
await datasources.save(datasource) |
|||
notifications.success(`Relationship information saved.`) |
|||
const tableList = await tables.fetch() |
|||
await tables.select(tableList.find(tbl => tbl._id === table._id)) |
|||
dispatch("updatecolumns") |
|||
} catch (err) { |
|||
notifications.error(`Error saving relationship info: ${err}`) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
{#if table.sourceId} |
|||
<div> |
|||
<ActionButton |
|||
icon="DataCorrelated" |
|||
primary |
|||
size="S" |
|||
quiet |
|||
on:click={modal.show} |
|||
> |
|||
Define existing relationship |
|||
</ActionButton> |
|||
</div> |
|||
<Modal bind:this={modal}> |
|||
<CreateEditRelationship |
|||
{datasource} |
|||
save={saveRelationship} |
|||
close={modal.hide} |
|||
{plusTables} |
|||
selectedFromTable={table} |
|||
/> |
|||
</Modal> |
|||
{/if} |
|||
@ -1,15 +0,0 @@ |
|||
<script> |
|||
import { Input } from "@budibase/bbui" |
|||
import { isJSBinding } from "@budibase/string-templates" |
|||
|
|||
export let value |
|||
|
|||
$: isJS = isJSBinding(value) |
|||
</script> |
|||
|
|||
<Input |
|||
{...$$props} |
|||
value={isJS ? "(JavaScript function)" : value} |
|||
readonly={isJS} |
|||
on:change |
|||
/> |
|||
@ -0,0 +1,45 @@ |
|||
<script> |
|||
import { ModalContent, Body, Input } from "@budibase/bbui" |
|||
import { tables, datasources } from "stores/backend" |
|||
import { goto } from "@roxi/routify" |
|||
|
|||
export let datasource |
|||
|
|||
let name = "" |
|||
$: valid = name && name.length > 0 && !datasource?.entities[name] |
|||
$: error = |
|||
name && datasource?.entities[name] ? "Table name already in use." : null |
|||
|
|||
function buildDefaultTable(tableName, datasourceId) { |
|||
return { |
|||
name: tableName, |
|||
type: "external", |
|||
primary: ["id"], |
|||
sourceId: datasourceId, |
|||
schema: { |
|||
id: { |
|||
autocolumn: true, |
|||
type: "number", |
|||
}, |
|||
}, |
|||
} |
|||
} |
|||
|
|||
async function saveTable() { |
|||
const table = await tables.save(buildDefaultTable(name, datasource._id)) |
|||
await datasources.fetch() |
|||
$goto(`../../table/${table._id}`) |
|||
} |
|||
</script> |
|||
|
|||
<ModalContent |
|||
title="Create new table" |
|||
confirmText="Create" |
|||
onConfirm={saveTable} |
|||
disabled={!valid} |
|||
> |
|||
<Body |
|||
>Provide a name for your new table; you can add columns once it is created.</Body |
|||
> |
|||
<Input label="Table Name" bind:error bind:value={name} /> |
|||
</ModalContent> |
|||
@ -1,43 +0,0 @@ |
|||
<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> |
|||
@ -0,0 +1,12 @@ |
|||
<script> |
|||
import { getContext, setContext } from "svelte" |
|||
|
|||
const component = getContext("component") |
|||
|
|||
// We need to set a block context to know we're inside a block, but also |
|||
// to be able to reference the actual component ID of the block from |
|||
// any depth |
|||
setContext("block", { id: $component.id }) |
|||
</script> |
|||
|
|||
<slot /> |
|||
@ -0,0 +1,35 @@ |
|||
<script> |
|||
import { getContext } from "svelte" |
|||
import { generate } from "shortid" |
|||
import Component from "components/Component.svelte" |
|||
|
|||
export let type |
|||
export let props |
|||
export let styles |
|||
export let context |
|||
|
|||
// ID is only exposed as a prop so that it can be bound to from parent |
|||
// block components |
|||
export let id |
|||
|
|||
const block = getContext("block") |
|||
const rand = generate() |
|||
|
|||
// Create a fake component instance so that we can use the core Component |
|||
// to render this part of the block, taking advantage of binding enrichment |
|||
$: id = `${block.id}-${context ?? rand}` |
|||
$: instance = { |
|||
_component: `@budibase/standard-components/${type}`, |
|||
_id: id, |
|||
_styles: { |
|||
normal: { |
|||
...styles, |
|||
}, |
|||
}, |
|||
...props, |
|||
} |
|||
</script> |
|||
|
|||
<Component {instance}> |
|||
<slot /> |
|||
</Component> |
|||
@ -0,0 +1,238 @@ |
|||
<script> |
|||
import { onMount, getContext } from "svelte" |
|||
import Block from "components/Block.svelte" |
|||
import BlockComponent from "components/BlockComponent.svelte" |
|||
import { Heading } from "@budibase/bbui" |
|||
|
|||
export let title |
|||
export let dataSource |
|||
export let searchColumns |
|||
export let filter |
|||
export let sortColumn |
|||
export let sortOrder |
|||
export let paginate |
|||
export let limit |
|||
export let showTitleButton |
|||
export let titleButtonText |
|||
export let titleButtonOnClick |
|||
export let cardTitle |
|||
export let cardSubtitle |
|||
export let cardDescription |
|||
export let cardImageURL |
|||
export let cardHorizontal |
|||
export let showCardButton |
|||
export let cardButtonText |
|||
export let cardButtonOnClick |
|||
|
|||
const { API, styleable } = getContext("sdk") |
|||
const context = getContext("context") |
|||
const component = getContext("component") |
|||
const schemaComponentMap = { |
|||
string: "stringfield", |
|||
options: "optionsfield", |
|||
number: "numberfield", |
|||
datetime: "datetimefield", |
|||
boolean: "booleanfield", |
|||
} |
|||
|
|||
let formId |
|||
let dataProviderId |
|||
let schema |
|||
|
|||
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) |
|||
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId) |
|||
$: cardWidth = cardHorizontal ? 420 : 300 |
|||
|
|||
// Enrich the default filter with the specified search fields |
|||
const enrichFilter = (filter, columns, formId) => { |
|||
let enrichedFilter = [...(filter || [])] |
|||
columns?.forEach(column => { |
|||
enrichedFilter.push({ |
|||
field: column.name, |
|||
operator: column.type === "string" ? "string" : "equal", |
|||
type: "string", |
|||
valueType: "Binding", |
|||
value: `{{ [${formId}].[${column.name}] }}`, |
|||
}) |
|||
}) |
|||
return enrichedFilter |
|||
} |
|||
|
|||
// Determine data types for search fields and only use those that are valid |
|||
const enrichSearchColumns = (searchColumns, schema) => { |
|||
let enrichedColumns = [] |
|||
searchColumns?.forEach(column => { |
|||
const schemaType = schema?.[column]?.type |
|||
const componentType = schemaComponentMap[schemaType] |
|||
if (componentType) { |
|||
enrichedColumns.push({ |
|||
name: column, |
|||
componentType, |
|||
type: schemaType, |
|||
}) |
|||
} |
|||
}) |
|||
return enrichedColumns.slice(0, 3) |
|||
} |
|||
|
|||
// Load the datasource schema on mount so we can determine column types |
|||
onMount(async () => { |
|||
if (dataSource) { |
|||
schema = await API.fetchDatasourceSchema(dataSource) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<Block> |
|||
<div class="card-list" use:styleable={$component.styles}> |
|||
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}> |
|||
{#if title || enrichedSearchColumns?.length || showTitleButton} |
|||
<div class="header" class:mobile={$context.device.mobile}> |
|||
<div class="title"> |
|||
<Heading>{title || ""}</Heading> |
|||
</div> |
|||
<div class="controls"> |
|||
{#if enrichedSearchColumns?.length} |
|||
<div |
|||
class="search" |
|||
style="--cols:{enrichedSearchColumns?.length}" |
|||
> |
|||
{#each enrichedSearchColumns as column} |
|||
<BlockComponent |
|||
type={column.componentType} |
|||
props={{ |
|||
field: column.name, |
|||
placeholder: column.name, |
|||
text: column.name, |
|||
autoWidth: true, |
|||
}} |
|||
/> |
|||
{/each} |
|||
</div> |
|||
{/if} |
|||
{#if showTitleButton} |
|||
<BlockComponent |
|||
type="button" |
|||
props={{ |
|||
onClick: titleButtonOnClick, |
|||
text: titleButtonText, |
|||
type: "cta", |
|||
}} |
|||
/> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
<BlockComponent |
|||
type="dataprovider" |
|||
bind:id={dataProviderId} |
|||
props={{ |
|||
dataSource, |
|||
filter: enrichedFilter, |
|||
sortColumn, |
|||
sortOrder, |
|||
paginate, |
|||
limit, |
|||
}} |
|||
> |
|||
<BlockComponent |
|||
type="repeater" |
|||
context="repeater" |
|||
props={{ |
|||
dataProvider: `{{ literal [${dataProviderId}] }}`, |
|||
direction: "row", |
|||
hAlign: "stretch", |
|||
vAlign: "top", |
|||
gap: "M", |
|||
noRowsMessage: "No rows found", |
|||
}} |
|||
styles={{ |
|||
display: "grid", |
|||
"grid-template-columns": `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`, |
|||
}} |
|||
> |
|||
<BlockComponent |
|||
type="spectrumcard" |
|||
props={{ |
|||
title: cardTitle, |
|||
subtitle: cardSubtitle, |
|||
description: cardDescription, |
|||
imageURL: cardImageURL, |
|||
horizontal: cardHorizontal, |
|||
showButton: showCardButton, |
|||
buttonText: cardButtonText, |
|||
buttonOnClick: cardButtonOnClick, |
|||
}} |
|||
styles={{ |
|||
width: "auto", |
|||
}} |
|||
/> |
|||
</BlockComponent> |
|||
</BlockComponent> |
|||
</BlockComponent> |
|||
</div> |
|||
</Block> |
|||
|
|||
<style> |
|||
.header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
gap: 20px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.title { |
|||
overflow: hidden; |
|||
} |
|||
.title :global(.spectrum-Heading) { |
|||
flex: 1 1 auto; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.controls { |
|||
flex: 0 1 auto; |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: flex-end; |
|||
align-items: center; |
|||
gap: 20px; |
|||
} |
|||
.controls :global(.spectrum-InputGroup .spectrum-InputGroup-input) { |
|||
width: 100%; |
|||
} |
|||
|
|||
.search { |
|||
flex: 0 1 auto; |
|||
gap: 10px; |
|||
max-width: 100%; |
|||
display: grid; |
|||
grid-template-columns: repeat(var(--cols), minmax(120px, 200px)); |
|||
} |
|||
.search :global(.spectrum-InputGroup) { |
|||
min-width: 0; |
|||
} |
|||
|
|||
/* Mobile styles */ |
|||
.mobile { |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
.mobile .controls { |
|||
flex-direction: column-reverse; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
.mobile .search { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
position: relative; |
|||
width: 100%; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,219 @@ |
|||
<script> |
|||
import { onMount, getContext } from "svelte" |
|||
import Block from "components/Block.svelte" |
|||
import BlockComponent from "components/BlockComponent.svelte" |
|||
import { Heading } from "@budibase/bbui" |
|||
|
|||
export let title |
|||
export let dataSource |
|||
export let searchColumns |
|||
export let filter |
|||
export let sortColumn |
|||
export let sortOrder |
|||
export let paginate |
|||
export let tableColumns |
|||
export let showAutoColumns |
|||
export let rowCount |
|||
export let quiet |
|||
export let size |
|||
export let linkRows |
|||
export let linkURL |
|||
export let linkColumn |
|||
export let linkPeek |
|||
export let showTitleButton |
|||
export let titleButtonText |
|||
export let titleButtonOnClick |
|||
|
|||
const { API, styleable } = getContext("sdk") |
|||
const context = getContext("context") |
|||
const component = getContext("component") |
|||
const schemaComponentMap = { |
|||
string: "stringfield", |
|||
options: "optionsfield", |
|||
number: "numberfield", |
|||
datetime: "datetimefield", |
|||
boolean: "booleanfield", |
|||
} |
|||
|
|||
let formId |
|||
let dataProviderId |
|||
let schema |
|||
|
|||
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) |
|||
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId) |
|||
|
|||
// Enrich the default filter with the specified search fields |
|||
const enrichFilter = (filter, columns, formId) => { |
|||
let enrichedFilter = [...(filter || [])] |
|||
columns?.forEach(column => { |
|||
enrichedFilter.push({ |
|||
field: column.name, |
|||
operator: column.type === "string" ? "string" : "equal", |
|||
type: "string", |
|||
valueType: "Binding", |
|||
value: `{{ [${formId}].[${column.name}] }}`, |
|||
}) |
|||
}) |
|||
return enrichedFilter |
|||
} |
|||
|
|||
// Determine data types for search fields and only use those that are valid |
|||
const enrichSearchColumns = (searchColumns, schema) => { |
|||
let enrichedColumns = [] |
|||
searchColumns?.forEach(column => { |
|||
const schemaType = schema?.[column]?.type |
|||
const componentType = schemaComponentMap[schemaType] |
|||
if (componentType) { |
|||
enrichedColumns.push({ |
|||
name: column, |
|||
componentType, |
|||
type: schemaType, |
|||
}) |
|||
} |
|||
}) |
|||
return enrichedColumns.slice(0, 3) |
|||
} |
|||
|
|||
// Load the datasource schema on mount so we can determine column types |
|||
onMount(async () => { |
|||
if (dataSource) { |
|||
schema = await API.fetchDatasourceSchema(dataSource) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<Block> |
|||
<div class={size} use:styleable={$component.styles}> |
|||
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}> |
|||
{#if title || enrichedSearchColumns?.length || showTitleButton} |
|||
<div class="header" class:mobile={$context.device.mobile}> |
|||
<div class="title"> |
|||
<Heading>{title || ""}</Heading> |
|||
</div> |
|||
<div class="controls"> |
|||
{#if enrichedSearchColumns?.length} |
|||
<div |
|||
class="search" |
|||
style="--cols:{enrichedSearchColumns?.length}" |
|||
> |
|||
{#each enrichedSearchColumns as column} |
|||
<BlockComponent |
|||
type={column.componentType} |
|||
props={{ |
|||
field: column.name, |
|||
placeholder: column.name, |
|||
text: column.name, |
|||
autoWidth: true, |
|||
}} |
|||
/> |
|||
{/each} |
|||
</div> |
|||
{/if} |
|||
{#if showTitleButton} |
|||
<BlockComponent |
|||
type="button" |
|||
props={{ |
|||
onClick: titleButtonOnClick, |
|||
text: titleButtonText, |
|||
type: "cta", |
|||
}} |
|||
/> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
<BlockComponent |
|||
type="dataprovider" |
|||
bind:id={dataProviderId} |
|||
props={{ |
|||
dataSource, |
|||
filter: enrichedFilter, |
|||
sortColumn, |
|||
sortOrder, |
|||
paginate, |
|||
limit: rowCount, |
|||
}} |
|||
> |
|||
<BlockComponent |
|||
type="table" |
|||
props={{ |
|||
dataProvider: `{{ literal [${dataProviderId}] }}`, |
|||
columns: tableColumns, |
|||
showAutoColumns, |
|||
rowCount, |
|||
quiet, |
|||
size, |
|||
linkRows, |
|||
linkURL, |
|||
linkColumn, |
|||
linkPeek, |
|||
}} |
|||
/> |
|||
</BlockComponent> |
|||
</BlockComponent> |
|||
</div> |
|||
</Block> |
|||
|
|||
<style> |
|||
.header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
gap: 20px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.title { |
|||
overflow: hidden; |
|||
} |
|||
.title :global(.spectrum-Heading) { |
|||
flex: 1 1 auto; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.controls { |
|||
flex: 0 1 auto; |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: flex-end; |
|||
align-items: center; |
|||
gap: 20px; |
|||
} |
|||
.controls :global(.spectrum-InputGroup .spectrum-InputGroup-input) { |
|||
width: 100%; |
|||
} |
|||
|
|||
.search { |
|||
flex: 0 1 auto; |
|||
gap: 10px; |
|||
max-width: 100%; |
|||
display: grid; |
|||
grid-template-columns: repeat(var(--cols), minmax(120px, 200px)); |
|||
} |
|||
.search :global(.spectrum-InputGroup) { |
|||
min-width: 0; |
|||
} |
|||
|
|||
/* Mobile styles */ |
|||
.mobile { |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
.mobile .controls { |
|||
flex-direction: column-reverse; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
.mobile .search { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
position: relative; |
|||
width: 100%; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,2 @@ |
|||
export { default as tablewithsearch } from "./TableWithSearch.svelte" |
|||
export { default as cardlistwithsearch } from "./CardListWithSearch.svelte" |
|||
@ -0,0 +1,9 @@ |
|||
FROM mcr.microsoft.com/mssql/server |
|||
|
|||
ENV ACCEPT_EULA=Y |
|||
ENV SA_PASSWORD=Passw0rd |
|||
|
|||
COPY ./data / |
|||
|
|||
ENTRYPOINT [ "/bin/bash", "entrypoint.sh" ] |
|||
CMD [ "/opt/mssql/bin/sqlservr" ] |
|||
@ -0,0 +1,24 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
if [ "$1" = '/opt/mssql/bin/sqlservr' ]; then |
|||
# If this is the container's first run, initialize the application database |
|||
if [ ! -f /tmp/app-initialized ]; then |
|||
# Initialize the application database asynchronously in a background process. This allows a) the SQL Server process to be the main process in the container, which allows graceful shutdown and other goodies, and b) us to only start the SQL Server process once, as opposed to starting, stopping, then starting it again. |
|||
function initialize_app_database() { |
|||
# Wait a bit for SQL Server to start. SQL Server's process doesn't provide a clever way to check if it's up or not, and it needs to be up before we can import the application database |
|||
sleep 30s |
|||
|
|||
echo "RUNNING BUDIBASE SETUP" |
|||
|
|||
#run the setup script to create the DB and the schema in the DB |
|||
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Passw0rd -i setup.sql |
|||
|
|||
# Note that the container has been initialized so future starts won't wipe changes to the data |
|||
touch /tmp/app-initialized |
|||
} |
|||
initialize_app_database & |
|||
fi |
|||
fi |
|||
|
|||
exec "$@" |
|||
@ -0,0 +1,54 @@ |
|||
USE master; |
|||
|
|||
IF OBJECT_ID ('dbo.products', 'U') IS NOT NULL |
|||
DROP TABLE products; |
|||
GO |
|||
CREATE TABLE products |
|||
( |
|||
id int IDENTITY(1,1), |
|||
name varchar (20), |
|||
description varchar(30), |
|||
CONSTRAINT pk_products PRIMARY KEY NONCLUSTERED (id) |
|||
); |
|||
|
|||
IF OBJECT_ID ('dbo.tasks', 'U') IS NOT NULL |
|||
DROP TABLE tasks; |
|||
GO |
|||
CREATE TABLE tasks |
|||
( |
|||
taskid int IDENTITY(1,1), |
|||
taskname varchar (20), |
|||
productid int, |
|||
CONSTRAINT pk_tasks PRIMARY KEY NONCLUSTERED (taskid), |
|||
CONSTRAINT fk_products FOREIGN KEY (productid) REFERENCES products (id), |
|||
); |
|||
|
|||
IF OBJECT_ID ('dbo.people', 'U') IS NOT NULL |
|||
DROP TABLE people; |
|||
GO |
|||
CREATE TABLE people |
|||
( |
|||
name varchar(30), |
|||
age varchar(20), |
|||
CONSTRAINT pk_people PRIMARY KEY NONCLUSTERED (name, age) |
|||
); |
|||
|
|||
INSERT products |
|||
(name, description) |
|||
VALUES |
|||
('Bananas', 'Fruit thing'); |
|||
|
|||
INSERT products |
|||
(name, description) |
|||
VALUES |
|||
('Meat', 'Animal thing'); |
|||
|
|||
INSERT tasks |
|||
(taskname, productid) |
|||
VALUES |
|||
('Processing', 1); |
|||
|
|||
INSERT people |
|||
(name, age) |
|||
VALUES |
|||
('Bob', '30'); |
|||
@ -0,0 +1,12 @@ |
|||
version: "3.8" |
|||
services: |
|||
# password: Passw0rd |
|||
# user: sa |
|||
# database: master |
|||
mssql: |
|||
image: bb/mssql |
|||
build: |
|||
context: . |
|||
dockerfile: data/Dockerfile |
|||
ports: |
|||
- "1433:1433" |
|||
@ -0,0 +1,3 @@ |
|||
#!/bin/bash |
|||
docker-compose down |
|||
docker volume prune -f |
|||
@ -0,0 +1,276 @@ |
|||
const CouchDB = require("../../../db") |
|||
const { |
|||
buildExternalTableId, |
|||
breakExternalTableId, |
|||
} = require("../../../integrations/utils") |
|||
const { |
|||
getTable, |
|||
generateForeignKey, |
|||
generateJunctionTableName, |
|||
foreignKeyStructure, |
|||
} = require("./utils") |
|||
const { |
|||
DataSourceOperation, |
|||
FieldTypes, |
|||
RelationshipTypes, |
|||
} = require("../../../constants") |
|||
const { makeExternalQuery } = require("../../../integrations/base/utils") |
|||
const { cloneDeep } = require("lodash/fp") |
|||
|
|||
async function makeTableRequest( |
|||
datasource, |
|||
operation, |
|||
table, |
|||
tables, |
|||
oldTable = null |
|||
) { |
|||
const json = { |
|||
endpoint: { |
|||
datasourceId: datasource._id, |
|||
entityId: table._id, |
|||
operation, |
|||
}, |
|||
meta: { |
|||
tables, |
|||
}, |
|||
table, |
|||
} |
|||
if (oldTable) { |
|||
json.meta.table = oldTable |
|||
} |
|||
return makeExternalQuery(datasource, json) |
|||
} |
|||
|
|||
function cleanupRelationships(table, tables, oldTable = null) { |
|||
const tableToIterate = oldTable ? oldTable : table |
|||
// clean up relationships in couch table schemas
|
|||
for (let [key, schema] of Object.entries(tableToIterate.schema)) { |
|||
if ( |
|||
schema.type === FieldTypes.LINK && |
|||
(!oldTable || table.schema[key] == null) |
|||
) { |
|||
const relatedTable = Object.values(tables).find( |
|||
table => table._id === schema.tableId |
|||
) |
|||
const foreignKey = schema.foreignKey |
|||
if (!relatedTable || !foreignKey) { |
|||
continue |
|||
} |
|||
for (let [relatedKey, relatedSchema] of Object.entries( |
|||
relatedTable.schema |
|||
)) { |
|||
if ( |
|||
relatedSchema.type === FieldTypes.LINK && |
|||
relatedSchema.fieldName === foreignKey |
|||
) { |
|||
delete relatedTable.schema[relatedKey] |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
function getDatasourceId(table) { |
|||
if (!table) { |
|||
throw "No table supplied" |
|||
} |
|||
if (table.sourceId) { |
|||
return table.sourceId |
|||
} |
|||
return breakExternalTableId(table._id).datasourceId |
|||
} |
|||
|
|||
function otherRelationshipType(type) { |
|||
if (type === RelationshipTypes.MANY_TO_MANY) { |
|||
return RelationshipTypes.MANY_TO_MANY |
|||
} |
|||
return type === RelationshipTypes.ONE_TO_MANY |
|||
? RelationshipTypes.MANY_TO_ONE |
|||
: RelationshipTypes.ONE_TO_MANY |
|||
} |
|||
|
|||
function generateManyLinkSchema(datasource, column, table, relatedTable) { |
|||
const primary = table.name + table.primary[0] |
|||
const relatedPrimary = relatedTable.name + relatedTable.primary[0] |
|||
const jcTblName = generateJunctionTableName(column, table, relatedTable) |
|||
// first create the new table
|
|||
const junctionTable = { |
|||
_id: buildExternalTableId(datasource._id, jcTblName), |
|||
name: jcTblName, |
|||
primary: [primary, relatedPrimary], |
|||
constrained: [primary, relatedPrimary], |
|||
schema: { |
|||
[primary]: foreignKeyStructure(primary, { |
|||
toTable: table.name, |
|||
toKey: table.primary[0], |
|||
}), |
|||
[relatedPrimary]: foreignKeyStructure(relatedPrimary, { |
|||
toTable: relatedTable.name, |
|||
toKey: relatedTable.primary[0], |
|||
}), |
|||
}, |
|||
} |
|||
column.through = junctionTable._id |
|||
column.throughFrom = primary |
|||
column.throughTo = relatedPrimary |
|||
column.fieldName = relatedPrimary |
|||
return junctionTable |
|||
} |
|||
|
|||
function generateLinkSchema(column, table, relatedTable, type) { |
|||
const isOneSide = type === RelationshipTypes.ONE_TO_MANY |
|||
const primary = isOneSide ? relatedTable.primary[0] : table.primary[0] |
|||
// generate a foreign key
|
|||
const foreignKey = generateForeignKey(column, relatedTable) |
|||
column.relationshipType = type |
|||
column.foreignKey = isOneSide ? foreignKey : primary |
|||
column.fieldName = isOneSide ? primary : foreignKey |
|||
return foreignKey |
|||
} |
|||
|
|||
function generateRelatedSchema(linkColumn, table, relatedTable, columnName) { |
|||
// generate column for other table
|
|||
const relatedSchema = cloneDeep(linkColumn) |
|||
// swap them from the main link
|
|||
if (linkColumn.foreignKey) { |
|||
relatedSchema.fieldName = linkColumn.foreignKey |
|||
relatedSchema.foreignKey = linkColumn.fieldName |
|||
} |
|||
// is many to many
|
|||
else { |
|||
// don't need to copy through, already got it
|
|||
relatedSchema.fieldName = linkColumn.throughFrom |
|||
relatedSchema.throughTo = linkColumn.throughFrom |
|||
relatedSchema.throughFrom = linkColumn.throughTo |
|||
} |
|||
relatedSchema.relationshipType = otherRelationshipType( |
|||
linkColumn.relationshipType |
|||
) |
|||
relatedSchema.tableId = relatedTable._id |
|||
relatedSchema.name = columnName |
|||
table.schema[columnName] = relatedSchema |
|||
} |
|||
|
|||
function isRelationshipSetup(column) { |
|||
return column.foreignKey || column.through |
|||
} |
|||
|
|||
exports.save = async function (ctx) { |
|||
const appId = ctx.appId |
|||
const table = ctx.request.body |
|||
// can't do this
|
|||
delete table.dataImport |
|||
const datasourceId = getDatasourceId(ctx.request.body) |
|||
let tableToSave = { |
|||
type: "table", |
|||
_id: buildExternalTableId(datasourceId, table.name), |
|||
...table, |
|||
} |
|||
|
|||
let oldTable |
|||
if (ctx.request.body && ctx.request.body._id) { |
|||
oldTable = await getTable(appId, ctx.request.body._id) |
|||
} |
|||
|
|||
const db = new CouchDB(appId) |
|||
const datasource = await db.get(datasourceId) |
|||
const oldTables = cloneDeep(datasource.entities) |
|||
const tables = datasource.entities |
|||
|
|||
const extraTablesToUpdate = [] |
|||
|
|||
// check if relations need setup
|
|||
for (let schema of Object.values(tableToSave.schema)) { |
|||
if (schema.type !== FieldTypes.LINK || isRelationshipSetup(schema)) { |
|||
continue |
|||
} |
|||
const relatedTable = Object.values(tables).find( |
|||
table => table._id === schema.tableId |
|||
) |
|||
const relatedColumnName = schema.fieldName |
|||
const relationType = schema.relationshipType |
|||
if (relationType === RelationshipTypes.MANY_TO_MANY) { |
|||
const junctionTable = generateManyLinkSchema( |
|||
datasource, |
|||
schema, |
|||
table, |
|||
relatedTable |
|||
) |
|||
if (tables[junctionTable.name]) { |
|||
throw "Junction table already exists, cannot create another relationship." |
|||
} |
|||
tables[junctionTable.name] = junctionTable |
|||
extraTablesToUpdate.push(junctionTable) |
|||
} else { |
|||
const fkTable = |
|||
relationType === RelationshipTypes.ONE_TO_MANY ? table : relatedTable |
|||
const foreignKey = generateLinkSchema( |
|||
schema, |
|||
table, |
|||
relatedTable, |
|||
relationType |
|||
) |
|||
fkTable.schema[foreignKey] = foreignKeyStructure(foreignKey) |
|||
if (fkTable.constrained == null) { |
|||
fkTable.constrained = [] |
|||
} |
|||
if (fkTable.constrained.indexOf(foreignKey) === -1) { |
|||
fkTable.constrained.push(foreignKey) |
|||
} |
|||
// foreign key is in other table, need to save it to external
|
|||
if (fkTable._id !== table._id) { |
|||
extraTablesToUpdate.push(fkTable) |
|||
} |
|||
} |
|||
generateRelatedSchema(schema, relatedTable, table, relatedColumnName) |
|||
schema.main = true |
|||
} |
|||
|
|||
cleanupRelationships(tableToSave, tables, oldTable) |
|||
|
|||
const operation = oldTable |
|||
? DataSourceOperation.UPDATE_TABLE |
|||
: DataSourceOperation.CREATE_TABLE |
|||
await makeTableRequest(datasource, operation, tableToSave, tables, oldTable) |
|||
// update any extra tables (like foreign keys in other tables)
|
|||
for (let extraTable of extraTablesToUpdate) { |
|||
const oldExtraTable = oldTables[extraTable.name] |
|||
let op = oldExtraTable |
|||
? DataSourceOperation.UPDATE_TABLE |
|||
: DataSourceOperation.CREATE_TABLE |
|||
await makeTableRequest(datasource, op, extraTable, tables, oldExtraTable) |
|||
} |
|||
|
|||
// make sure the constrained list, all still exist
|
|||
if (Array.isArray(tableToSave.constrained)) { |
|||
tableToSave.constrained = tableToSave.constrained.filter(constraint => |
|||
Object.keys(tableToSave.schema).includes(constraint) |
|||
) |
|||
} |
|||
|
|||
// store it into couch now for budibase reference
|
|||
datasource.entities[tableToSave.name] = tableToSave |
|||
await db.put(datasource) |
|||
|
|||
return tableToSave |
|||
} |
|||
|
|||
exports.destroy = async function (ctx) { |
|||
const appId = ctx.appId |
|||
const tableToDelete = await getTable(appId, ctx.params.tableId) |
|||
const datasourceId = getDatasourceId(tableToDelete) |
|||
|
|||
const db = new CouchDB(appId) |
|||
const datasource = await db.get(datasourceId) |
|||
const tables = datasource.entities |
|||
|
|||
const operation = DataSourceOperation.DELETE_TABLE |
|||
await makeTableRequest(datasource, operation, tableToDelete, tables) |
|||
|
|||
cleanupRelationships(tableToDelete, tables) |
|||
|
|||
delete datasource.entities[tableToDelete.name] |
|||
await db.put(datasource) |
|||
|
|||
return tableToDelete |
|||
} |
|||
@ -0,0 +1,138 @@ |
|||
const CouchDB = require("../../../db") |
|||
const linkRows = require("../../../db/linkedRows") |
|||
const { getRowParams, generateTableID } = require("../../../db/utils") |
|||
const { FieldTypes } = require("../../../constants") |
|||
const { TableSaveFunctions } = require("./utils") |
|||
|
|||
exports.save = async function (ctx) { |
|||
const appId = ctx.appId |
|||
const db = new CouchDB(appId) |
|||
const { dataImport, ...rest } = ctx.request.body |
|||
let tableToSave = { |
|||
type: "table", |
|||
_id: generateTableID(), |
|||
views: {}, |
|||
...rest, |
|||
} |
|||
|
|||
// if the table obj had an _id then it will have been retrieved
|
|||
let oldTable |
|||
if (ctx.request.body && ctx.request.body._id) { |
|||
oldTable = await db.get(ctx.request.body._id) |
|||
} |
|||
|
|||
// saving a table is a complex operation, involving many different steps, this
|
|||
// has been broken out into a utility to make it more obvious/easier to manipulate
|
|||
const tableSaveFunctions = new TableSaveFunctions({ |
|||
db, |
|||
ctx, |
|||
oldTable, |
|||
dataImport, |
|||
}) |
|||
tableToSave = await tableSaveFunctions.before(tableToSave) |
|||
|
|||
// make sure that types don't change of a column, have to remove
|
|||
// the column if you want to change the type
|
|||
if (oldTable && oldTable.schema) { |
|||
for (let propKey of Object.keys(tableToSave.schema)) { |
|||
let column = tableToSave.schema[propKey] |
|||
let oldColumn = oldTable.schema[propKey] |
|||
if (oldColumn && oldColumn.type === "internal") { |
|||
oldColumn.type = "auto" |
|||
} |
|||
if (oldColumn && oldColumn.type !== column.type) { |
|||
ctx.throw(400, "Cannot change the type of a column") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Don't rename if the name is the same
|
|||
let { _rename } = tableToSave |
|||
/* istanbul ignore next */ |
|||
if (_rename && _rename.old === _rename.updated) { |
|||
_rename = null |
|||
delete tableToSave._rename |
|||
} |
|||
|
|||
// rename row fields when table column is renamed
|
|||
/* istanbul ignore next */ |
|||
if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) { |
|||
ctx.throw(400, "Cannot rename a linked column.") |
|||
} |
|||
|
|||
tableToSave = await tableSaveFunctions.mid(tableToSave) |
|||
|
|||
// update schema of non-statistics views when new columns are added
|
|||
for (let view in tableToSave.views) { |
|||
const tableView = tableToSave.views[view] |
|||
if (!tableView) continue |
|||
|
|||
if (tableView.schema.group || tableView.schema.field) continue |
|||
tableView.schema = tableToSave.schema |
|||
} |
|||
|
|||
// update linked rows
|
|||
try { |
|||
const linkResp = await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: oldTable |
|||
? linkRows.EventType.TABLE_UPDATED |
|||
: linkRows.EventType.TABLE_SAVE, |
|||
table: tableToSave, |
|||
oldTable: oldTable, |
|||
}) |
|||
if (linkResp != null && linkResp._rev) { |
|||
tableToSave._rev = linkResp._rev |
|||
} |
|||
} catch (err) { |
|||
ctx.throw(400, err) |
|||
} |
|||
|
|||
// don't perform any updates until relationships have been
|
|||
// checked by the updateLinks function
|
|||
const updatedRows = tableSaveFunctions.getUpdatedRows() |
|||
if (updatedRows && updatedRows.length !== 0) { |
|||
await db.bulkDocs(updatedRows) |
|||
} |
|||
const result = await db.put(tableToSave) |
|||
tableToSave._rev = result.rev |
|||
|
|||
tableToSave = await tableSaveFunctions.after(tableToSave) |
|||
|
|||
return tableToSave |
|||
} |
|||
|
|||
exports.destroy = async function (ctx) { |
|||
const appId = ctx.appId |
|||
const db = new CouchDB(appId) |
|||
const tableToDelete = await db.get(ctx.params.tableId) |
|||
|
|||
// Delete all rows for that table
|
|||
const rows = await db.allDocs( |
|||
getRowParams(ctx.params.tableId, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
await db.bulkDocs(rows.rows.map(row => ({ ...row.doc, _deleted: true }))) |
|||
|
|||
// update linked rows
|
|||
await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: linkRows.EventType.TABLE_DELETE, |
|||
table: tableToDelete, |
|||
}) |
|||
|
|||
// don't remove the table itself until very end
|
|||
await db.remove(tableToDelete) |
|||
|
|||
// remove table search index
|
|||
const currentIndexes = await db.getIndexes() |
|||
const existingIndex = currentIndexes.indexes.find( |
|||
existing => existing.name === `search:${ctx.params.tableId}` |
|||
) |
|||
if (existingIndex) { |
|||
await db.deleteIndex(existingIndex) |
|||
} |
|||
|
|||
return tableToDelete |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue