Merge pull request #12659 from Budibase/BUDI-7656/add-multiple-relationships-dev-script

Add multiple relationships dev script
This commit is contained in:
Adria Navarro 2024-01-02 12:47:04 +01:00 committed by GitHub
commit 4b38f18cc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 661 additions and 130 deletions

@ -1 +1 @@
Subproject commit d6a1f89aa543bdce7acde5fbe4ce650a1344e2fe Subproject commit c1a53bb2f4cafcb4c55ad7181146617b449907f2

View File

@ -18,14 +18,15 @@ export enum TTL {
ONE_DAY = 86400, ONE_DAY = 86400,
} }
function performExport(funcName: string) { export const keys = (...args: Parameters<typeof GENERIC.keys>) =>
// @ts-ignore GENERIC.keys(...args)
return (...args: any) => GENERIC[funcName](...args) export const get = (...args: Parameters<typeof GENERIC.get>) =>
} GENERIC.get(...args)
export const store = (...args: Parameters<typeof GENERIC.store>) =>
export const keys = performExport("keys") GENERIC.store(...args)
export const get = performExport("get") export const destroy = (...args: Parameters<typeof GENERIC.delete>) =>
export const store = performExport("store") GENERIC.delete(...args)
export const destroy = performExport("delete") export const withCache = (...args: Parameters<typeof GENERIC.withCache>) =>
export const withCache = performExport("withCache") GENERIC.withCache(...args)
export const bustCache = performExport("bustCache") export const bustCache = (...args: Parameters<typeof GENERIC.bustCache>) =>
GENERIC.bustCache(...args)

View File

@ -0,0 +1,196 @@
#!/bin/node
const {
createApp,
getTable,
createRow,
createTable,
getApp,
getRows,
} = require("./utils")
const Chance = require("chance")
const generator = new Chance()
const STUDENT_COUNT = 500
const SUBJECT_COUNT = 10
let { apiKey, appId } = require("yargs")
.demandOption(["apiKey"])
.option("appId").argv
const start = Date.now()
async function batchCreate(apiKey, appId, table, items, batchSize = 100) {
let i = 0
let errors = 0
async function createSingleRow(item) {
try {
const row = await createRow(apiKey, appId, table, item)
console.log(
`${table.name} - ${++i} of ${items.length} created (${
(Date.now() - start) / 1000
}s)`
)
return row
} catch {
errors++
}
}
const rows = []
const maxConcurrency = Math.min(batchSize, items.length)
const inFlight = {}
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const item = items[itemIndex]
const promise = createSingleRow(item)
.then(result => {
rows.push(result)
})
.finally(() => {
delete inFlight[itemIndex]
})
inFlight[itemIndex] = promise
if (Object.keys(inFlight).length >= maxConcurrency) {
await Promise.race(Object.values(inFlight))
}
}
await Promise.all(Object.values(inFlight))
if (errors) {
console.error(
`${table.name} - ${errors} creation errored (${
(Date.now() - start) / 1000
}s)`
)
}
return rows
}
const useExistingApp = !!appId
async function upsertTable(appId, tableName, tableData) {
if (useExistingApp) {
return await getTable(apiKey, appId, tableName)
}
const table = await createTable(apiKey, appId, {
...tableData,
name: tableName,
})
return table
}
async function run() {
if (!appId) {
const app = appId ? await getApp(apiKey, appId) : await createApp(apiKey)
appId = app._id
console.log(`App created. Url: http://localhost:10000/builder/app/${appId}`)
} else {
console.log(
`App retrieved. Url: http://localhost:10000/builder/app/${appId}`
)
}
const studentsTable = await getTable(apiKey, appId, "Students")
let studentNumber = studentsTable.schema["Auto ID"].lastID
const students = await batchCreate(
apiKey,
appId,
studentsTable,
Array.from({ length: STUDENT_COUNT }).map(() => ({
"Student Number": (++studentNumber).toString(),
"First Name": generator.first(),
"Last Name": generator.last(),
Gender: generator.pickone(["M", "F"]),
Grade: generator.pickone(["8", "9", "10", "11"]),
"Tardiness (Days)": generator.integer({ min: 1, max: 100 }),
"Home Number": generator.phone(),
"Attendance_(%)": generator.integer({ min: 0, max: 100 }),
}))
)
const subjectTable = await upsertTable(appId, "Subjects", {
schema: {
Name: {
name: "Name",
type: "string",
},
},
primaryDisplay: "Name",
})
const subjects = useExistingApp
? await getRows(apiKey, appId, subjectTable._id)
: await batchCreate(
apiKey,
appId,
subjectTable,
Array.from({ length: SUBJECT_COUNT }).map(() => ({
Name: generator.profession(),
}))
)
const gradesTable = await upsertTable(appId, "Grades", {
schema: {
Score: {
name: "Score",
type: "number",
},
Student: {
name: "Student",
tableId: studentsTable._id,
constraints: {
presence: true,
type: "array",
},
fieldName: "Grades",
relationshipType: "one-to-many",
type: "link",
},
Subject: {
name: "Subject",
tableId: subjectTable._id,
constraints: {
presence: true,
type: "array",
},
fieldName: "Grades",
relationshipType: "one-to-many",
type: "link",
},
},
})
await batchCreate(
apiKey,
appId,
gradesTable,
students.flatMap(student =>
subjects.map(subject => ({
Score: generator.integer({ min: 0, max: 100 }),
Student: [student],
Subject: [subject],
}))
)
)
console.log(
`Access the app here: http://localhost:10000/builder/app/${appId}`
)
}
run()
.then(() => {
console.log(`Done in ${(Date.now() - start) / 1000} seconds`)
})
.catch(err => {
console.error(err)
})

View File

@ -0,0 +1,29 @@
#!/bin/node
const { searchApps, deleteApp } = require("./utils")
if (!process.argv[2]) {
console.error("Please specify an API key as script argument.")
process.exit(-1)
}
async function run() {
const apiKey = process.argv[2]
const apps = await searchApps(apiKey)
console.log(`Deleting ${apps.length} apps`)
let deletedApps = 0
await Promise.all(
apps.map(async app => {
await deleteApp(apiKey, app._id)
console.log(`App ${++deletedApps} of ${apps.length} deleted`)
})
)
}
run()
.then(() => {
console.log("Done!")
})
.catch(err => {
console.error(err)
})

View File

@ -2,7 +2,8 @@ const fetch = require("node-fetch")
const uuid = require("uuid/v4") const uuid = require("uuid/v4")
const URL_APP = "http://localhost:10000/api/public/v1/applications" const URL_APP = "http://localhost:10000/api/public/v1/applications"
const URL_TABLE = "http://localhost:10000/api/public/v1/tables/search" const URL_TABLE = "http://localhost:10000/api/public/v1/tables"
const URL_SEARCH_TABLE = "http://localhost:10000/api/public/v1/tables/search"
async function request(apiKey, url, method, body, appId = undefined) { async function request(apiKey, url, method, body, appId = undefined) {
const headers = { const headers = {
@ -37,30 +38,64 @@ exports.createApp = async apiKey => {
return json.data return json.data
} }
exports.getTable = async (apiKey, appId) => { exports.getApp = async (apiKey, appId) => {
const res = await request(apiKey, URL_TABLE, "POST", {}, appId) const res = await request(apiKey, `${URL_APP}/${appId}`, "GET")
const json = await res.json() const json = await res.json()
return json.data[0] return json.data
}
exports.searchApps = async apiKey => {
const res = await request(apiKey, `${URL_APP}/search`, "POST", {})
const json = await res.json()
return json.data
} }
exports.createRow = async (apiKey, appId, table) => { exports.deleteApp = async (apiKey, appId) => {
const body = {} const res = await request(apiKey, `${URL_APP}/${appId}`, "DELETE")
for (let [key, schema] of Object.entries(table.schema)) { return res
let fake }
switch (schema.type) {
default: exports.getTable = async (apiKey, appId, tableName) => {
case "string": const res = await request(apiKey, URL_SEARCH_TABLE, "POST", {}, appId)
fake = schema.constraints.inclusion const json = await res.json()
? schema.constraints.inclusion[0] const table = json.data.find(t => t.name === tableName)
: "a" if (!table) {
break throw `Table '${tableName} not found`
case "number": }
fake = 1 return table
break }
exports.createRow = async (apiKey, appId, table, body) => {
if (!body) {
body = {}
for (let [key, schema] of Object.entries(table.schema)) {
let fake
switch (schema.type) {
default:
case "string":
fake = schema.constraints?.inclusion
? schema.constraints.inclusion[0]
: "a"
break
case "number":
fake = 1
break
}
body[key] = fake
} }
body[key] = fake
} }
const url = `http://localhost:10000/api/public/v1/tables/${table._id}/rows` const url = `http://localhost:10000/api/public/v1/tables/${table._id}/rows`
const res = await request(apiKey, url, "POST", body, appId) const res = await request(apiKey, url, "POST", body, appId)
return (await res.json()).data return (await res.json()).data
} }
exports.getRows = async (apiKey, appId, tableId) => {
const url = `${URL_TABLE}/${tableId}/rows/search`
const res = await request(apiKey, url, "POST", {}, appId)
return (await res.json()).data
}
exports.createTable = async (apiKey, appId, config) => {
const res = await request(apiKey, URL_TABLE, "POST", config, appId)
const json = await res.json()
return json.data
}

View File

@ -26,7 +26,7 @@ import {
inputProcessing, inputProcessing,
outputProcessing, outputProcessing,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { cloneDeep, isEqual } from "lodash" import { cloneDeep } from "lodash"
export async function handleRequest<T extends Operation>( export async function handleRequest<T extends Operation>(
operation: T, operation: T,
@ -86,50 +86,6 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
} }
} }
export async function save(ctx: UserCtx) {
const inputs = ctx.request.body
const tableId = utils.getTableId(ctx)
const table = await sdk.tables.getTable(tableId)
const { table: updatedTable, row } = await inputProcessing(
ctx.user?._id,
cloneDeep(table),
inputs
)
const validateResult = await sdk.rows.utils.validate({
row,
tableId,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
const response = await handleRequest(Operation.CREATE, tableId, {
row,
})
if (!isEqual(table, updatedTable)) {
await sdk.tables.saveTable(updatedTable)
}
const rowId = response.row._id
if (rowId) {
const row = await sdk.rows.external.getRow(tableId, rowId, {
relationships: true,
})
return {
...response,
row: await outputProcessing(table, row, {
preserveLinks: true,
squash: true,
}),
}
} else {
return response
}
}
export async function find(ctx: UserCtx): Promise<Row> { export async function find(ctx: UserCtx): Promise<Row> {
const id = ctx.params.rowId const id = ctx.params.rowId
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)

View File

@ -30,7 +30,7 @@ import { Format } from "../view/exporters"
export * as views from "./views" export * as views from "./views"
function pickApi(tableId: any) { function pickApi(tableId: string) {
if (isExternalTableID(tableId)) { if (isExternalTableID(tableId)) {
return external return external
} }
@ -84,9 +84,12 @@ export const save = async (ctx: UserCtx<Row, Row>) => {
return patch(ctx as UserCtx<PatchRowRequest, PatchRowResponse>) return patch(ctx as UserCtx<PatchRowRequest, PatchRowResponse>)
} }
const { row, table, squashed } = await quotas.addRow(() => const { row, table, squashed } = await quotas.addRow(() =>
quotas.addQuery(() => pickApi(tableId).save(ctx), { quotas.addQuery(
datasourceId: tableId, () => sdk.rows.save(tableId, ctx.request.body, ctx.user?._id),
}) {
datasourceId: tableId,
}
)
) )
ctx.status = 200 ctx.status = 200
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)

View File

@ -1,5 +1,5 @@
import * as linkRows from "../../../db/linkedRows" import * as linkRows from "../../../db/linkedRows"
import { generateRowID, InternalTables } from "../../../db/utils" import { InternalTables } from "../../../db/utils"
import * as userController from "../user" import * as userController from "../user"
import { import {
AttachmentCleanup, AttachmentCleanup,
@ -94,45 +94,6 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
}) })
} }
export async function save(ctx: UserCtx) {
let inputs = ctx.request.body
inputs.tableId = utils.getTableId(ctx)
if (!inputs._rev && !inputs._id) {
inputs._id = generateRowID(inputs.tableId)
}
// this returns the table and row incase they have been updated
const dbTable = await sdk.tables.getTable(inputs.tableId)
// need to copy the table so it can be differenced on way out
const tableClone = cloneDeep(dbTable)
let { table, row } = await inputProcessing(ctx.user?._id, tableClone, inputs)
const validateResult = await sdk.rows.utils.validate({
row,
table,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
// make sure link rows are up-to-date
row = (await linkRows.updateLinks({
eventType: linkRows.EventType.ROW_SAVE,
row,
tableId: row.tableId,
table,
})) as Row
return finaliseRow(table, row, {
oldTable: dbTable,
updateFormula: true,
})
}
export async function find(ctx: UserCtx): Promise<Row> { export async function find(ctx: UserCtx): Promise<Row> {
const tableId = utils.getTableId(ctx), const tableId = utils.getTableId(ctx),
rowId = ctx.params.rowId rowId = ctx.params.rowId

View File

@ -5,8 +5,8 @@ import {
processFormulas, processFormulas,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { FieldTypes, FormulaTypes } from "../../../constants" import { FieldTypes, FormulaTypes } from "../../../constants"
import { context } from "@budibase/backend-core" import { context, locks } from "@budibase/backend-core"
import { Table, Row } from "@budibase/types" import { Table, Row, LockType, LockName } from "@budibase/types"
import * as linkRows from "../../../db/linkedRows" import * as linkRows from "../../../db/linkedRows"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
@ -149,12 +149,22 @@ export async function finaliseRow(
await db.put(table) await db.put(table)
} catch (err: any) { } catch (err: any) {
if (err.status === 409) { if (err.status === 409) {
const updatedTable = await sdk.tables.getTable(table._id!) // Some conflicts with the autocolumns occurred, we need to refetch the table and recalculate
let response = processAutoColumn(null, updatedTable, row, { await locks.doWithLock(
reprocessing: true, {
}) type: LockType.AUTO_EXTEND,
await db.put(response.table) name: LockName.PROCESS_AUTO_COLUMNS,
row = response.row resource: table._id,
},
async () => {
const latestTable = await sdk.tables.getTable(table._id!)
let response = processAutoColumn(null, latestTable, row, {
reprocessing: true,
})
await db.put(response.table)
row = response.row
}
)
} else { } else {
throw err throw err
} }

View File

@ -77,7 +77,7 @@ const publicRouter = new Router({
prefix: PREFIX, prefix: PREFIX,
}) })
if (limiter) { if (limiter && !env.isDev()) {
publicRouter.use(limiter) publicRouter.use(limiter)
} }

View File

@ -1,6 +1,13 @@
import { IncludeRelationship, Operation } from "@budibase/types" import { IncludeRelationship, Operation, Row } from "@budibase/types"
import { handleRequest } from "../../../api/controllers/row/external" import { handleRequest } from "../../../api/controllers/row/external"
import { breakRowIdField } from "../../../integrations/utils" import { breakRowIdField } from "../../../integrations/utils"
import sdk from "../../../sdk"
import {
inputProcessing,
outputProcessing,
} from "../../../utilities/rowProcessor"
import cloneDeep from "lodash/fp/cloneDeep"
import isEqual from "lodash/fp/isEqual"
export async function getRow( export async function getRow(
tableId: string, tableId: string,
@ -15,3 +22,48 @@ export async function getRow(
}) })
return response ? response[0] : response return response ? response[0] : response
} }
export async function save(
tableId: string,
inputs: Row,
userId: string | undefined
) {
const table = await sdk.tables.getTable(tableId)
const { table: updatedTable, row } = await inputProcessing(
userId,
cloneDeep(table),
inputs
)
const validateResult = await sdk.rows.utils.validate({
row,
tableId,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
const response = await handleRequest(Operation.CREATE, tableId, {
row,
})
if (!isEqual(table, updatedTable)) {
await sdk.tables.saveTable(updatedTable)
}
const rowId = response.row._id
if (rowId) {
const row = await sdk.rows.external.getRow(tableId, rowId, {
relationships: true,
})
return {
...response,
row: await outputProcessing(table, row, {
preserveLinks: true,
squash: true,
}),
}
} else {
return response
}
}

View File

@ -0,0 +1,49 @@
import { db } from "@budibase/backend-core"
import { Row } from "@budibase/types"
import sdk from "../../../sdk"
import cloneDeep from "lodash/fp/cloneDeep"
import { finaliseRow } from "../../../api/controllers/row/staticFormula"
import { inputProcessing } from "../../../utilities/rowProcessor"
import * as linkRows from "../../../db/linkedRows"
export async function save(
tableId: string,
inputs: Row,
userId: string | undefined
) {
inputs.tableId = tableId
if (!inputs._rev && !inputs._id) {
inputs._id = db.generateRowID(inputs.tableId)
}
// this returns the table and row incase they have been updated
const dbTable = await sdk.tables.getTable(inputs.tableId)
// need to copy the table so it can be differenced on way out
const tableClone = cloneDeep(dbTable)
let { table, row } = await inputProcessing(userId, tableClone, inputs)
const validateResult = await sdk.rows.utils.validate({
row,
table,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
// make sure link rows are up-to-date
row = (await linkRows.updateLinks({
eventType: linkRows.EventType.ROW_SAVE,
row,
tableId: row.tableId,
table,
})) as Row
return finaliseRow(table, row, {
oldTable: dbTable,
updateFormula: true,
})
}

View File

@ -1,6 +1,9 @@
import { db as dbCore, context } from "@budibase/backend-core" import { db as dbCore, context } from "@budibase/backend-core"
import { Database, Row } from "@budibase/types" import { Database, Row } from "@budibase/types"
import { getRowParams } from "../../../db/utils" import { getRowParams } from "../../../db/utils"
import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal"
import * as external from "./external"
export async function getAllInternalRows(appId?: string) { export async function getAllInternalRows(appId?: string) {
let db: Database let db: Database
@ -16,3 +19,18 @@ export async function getAllInternalRows(appId?: string) {
) )
return response.rows.map(row => row.doc) as Row[] return response.rows.map(row => row.doc) as Row[]
} }
function pickApi(tableId: any) {
if (isExternalTableID(tableId)) {
return external
}
return internal
}
export async function save(
tableId: string,
row: Row,
userId: string | undefined
) {
return pickApi(tableId).save(tableId, row, userId)
}

View File

@ -0,0 +1,220 @@
import tk from "timekeeper"
import * as internalSdk from "../internal"
import { generator } from "@budibase/backend-core/tests"
import {
INTERNAL_TABLE_SOURCE_ID,
TableSourceType,
FieldType,
Table,
AutoFieldSubTypes,
} from "@budibase/types"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { cache } from "@budibase/backend-core"
tk.freeze(Date.now())
describe("sdk >> rows >> internal", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
function makeRow() {
return {
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
}
}
describe("save", () => {
const tableData: Table = {
name: generator.word(),
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
surname: {
name: "surname",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
age: {
name: "age",
type: FieldType.NUMBER,
constraints: {
type: FieldType.NUMBER,
},
},
address: {
name: "address",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
},
}
beforeEach(() => {
jest.clearAllMocks()
})
it("save will persist the row properly", async () => {
const table = await config.createTable(tableData)
const row = makeRow()
await config.doInContext(config.appId, async () => {
const response = await internalSdk.save(
table._id!,
row,
config.user._id
)
expect(response).toEqual({
table,
row: {
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
squashed: {
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
})
const persistedRow = await config.getRow(table._id!, response.row._id!)
expect(persistedRow).toEqual({
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
})
})
it("auto ids will update when creating new rows", async () => {
const table = await config.createTable({
...tableData,
schema: {
...tableData.schema,
id: {
name: "id",
type: FieldType.AUTO,
subtype: AutoFieldSubTypes.AUTO_ID,
autocolumn: true,
lastID: 0,
},
},
})
const row = makeRow()
await config.doInContext(config.appId, async () => {
const response = await internalSdk.save(
table._id!,
row,
config.user._id
)
expect(response).toEqual({
table: {
...table,
schema: {
...table.schema,
id: {
...table.schema.id,
lastID: 1,
},
},
},
row: {
...row,
id: 1,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
squashed: {
...row,
id: 1,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
})
const persistedRow = await config.getRow(table._id!, response.row._id!)
expect(persistedRow).toEqual({
...row,
type: "row",
id: 1,
_rev: expect.stringMatching("1-.*"),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
})
})
it("auto ids will update when creating new rows in parallel", async () => {
function makeRows(count: number) {
return Array.from({ length: count }, () => makeRow())
}
const table = await config.createTable({
...tableData,
schema: {
...tableData.schema,
id: {
name: "id",
type: FieldType.AUTO,
subtype: AutoFieldSubTypes.AUTO_ID,
autocolumn: true,
lastID: 0,
},
},
})
await config.doInContext(config.appId, async () => {
for (const row of makeRows(5)) {
await internalSdk.save(table._id!, row, config.user._id)
}
await Promise.all(
makeRows(10).map(row =>
internalSdk.save(table._id!, row, config.user._id)
)
)
for (const row of makeRows(5)) {
await internalSdk.save(table._id!, row, config.user._id)
}
})
const persistedRows = await config.getRows(table._id!)
expect(persistedRows).toHaveLength(20)
expect(persistedRows).toEqual(
expect.arrayContaining(
Array.from({ length: 20 }).map((_, i) =>
expect.objectContaining({ id: i + 1 })
)
)
)
const persistedTable = await config.getTable(table._id)
expect((table as any).schema.id.lastID).toBe(0)
expect(persistedTable.schema.id.lastID).toBe(20)
})
})
})

View File

@ -21,6 +21,7 @@ export enum LockName {
PERSIST_WRITETHROUGH = "persist_writethrough", PERSIST_WRITETHROUGH = "persist_writethrough",
QUOTA_USAGE_EVENT = "quota_usage_event", QUOTA_USAGE_EVENT = "quota_usage_event",
APP_MIGRATION = "app_migrations", APP_MIGRATION = "app_migrations",
PROCESS_AUTO_COLUMNS = "process_auto_columns",
} }
export type LockOptions = { export type LockOptions = {