1
0
Fork 0
mirror of synced 2024-09-11 06:56:23 +12:00

Merge pull request #9368 from Budibase/test/9339-sqlpostgres-row-api-test-suite

Test - #9339 sqlpostgres row api test suite
This commit is contained in:
Michael Drury 2023-02-07 15:38:09 +00:00 committed by GitHub
commit a353848152
18 changed files with 882 additions and 84 deletions

View file

@ -1,4 +1,7 @@
function getTestContainerSettings(serverName: string, key: string) { function getTestContainerSettings(
serverName: string,
key: string
): string | null {
const entry = Object.entries(global).find( const entry = Object.entries(global).find(
([k]) => ([k]) =>
k.includes(`_${serverName.toUpperCase()}`) && k.includes(`_${serverName.toUpperCase()}`) &&
@ -10,20 +13,25 @@ function getTestContainerSettings(serverName: string, key: string) {
return entry[1] return entry[1]
} }
function getCouchConfig() { function getContainerInfo(containerName: string, port: number) {
const port = getTestContainerSettings("COUCHDB-SERVICE", "PORT_5984") const assignedPort = getTestContainerSettings(
containerName.toUpperCase(),
`PORT_${port}`
)
const host = getTestContainerSettings(containerName.toUpperCase(), "IP")
return { return {
port, port: assignedPort,
url: `http://${getTestContainerSettings("COUCHDB-SERVICE", "IP")}:${port}`, host,
url: host && assignedPort && `http://${host}:${assignedPort}`,
} }
} }
function getCouchConfig() {
return getContainerInfo("couchdb-service", 5984)
}
function getMinioConfig() { function getMinioConfig() {
const port = getTestContainerSettings("MINIO-SERVICE", "PORT_9000") return getContainerInfo("minio-service", 9000)
return {
port,
url: `http://${getTestContainerSettings("MINIO-SERVICE", "IP")}:${port}`,
}
} }
export function setupEnv(...envs: any[]) { export function setupEnv(...envs: any[]) {
@ -34,7 +42,7 @@ export function setupEnv(...envs: any[]) {
{ key: "MINIO_URL", value: getMinioConfig().url }, { key: "MINIO_URL", value: getMinioConfig().url },
] ]
for (const config of configs.filter(x => x.value !== null)) { for (const config of configs.filter(x => !!x.value)) {
for (const env of envs) { for (const env of envs) {
env._set(config.key, config.value) env._set(config.key, config.value)
} }

View file

@ -10,6 +10,7 @@ import {
FieldSchema, FieldSchema,
Row, Row,
Table, Table,
RelationshipTypes,
} from "@budibase/types" } from "@budibase/types"
import { import {
breakRowIdField, breakRowIdField,
@ -18,7 +19,7 @@ import {
convertRowId, convertRowId,
} from "../../../integrations/utils" } from "../../../integrations/utils"
import { getDatasourceAndQuery } from "./utils" import { getDatasourceAndQuery } from "./utils"
import { FieldTypes, RelationshipTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
import { breakExternalTableId, isSQL } from "../../../integrations/utils" import { breakExternalTableId, isSQL } from "../../../integrations/utils"
import { processObjectSync } from "@budibase/string-templates" import { processObjectSync } from "@budibase/string-templates"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -44,6 +45,7 @@ export interface RunConfig {
row?: Row row?: Row
rows?: Row[] rows?: Row[]
tables?: Record<string, Table> tables?: Record<string, Table>
includeSqlRelationships?: IncludeRelationship
} }
function buildFilters( function buildFilters(
@ -706,7 +708,9 @@ export class ExternalRequest {
}, },
resource: { resource: {
// have to specify the fields to avoid column overlap (for SQL) // have to specify the fields to avoid column overlap (for SQL)
fields: isSql ? this.buildFields(table) : [], fields: isSql
? this.buildFields(table, config.includeSqlRelationships)
: [],
}, },
filters, filters,
sort, sort,

View file

@ -18,6 +18,7 @@ import {
PaginationJson, PaginationJson,
Table, Table,
Datasource, Datasource,
IncludeRelationship,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
@ -57,6 +58,7 @@ export async function patch(ctx: BBContext) {
return handleRequest(Operation.UPDATE, tableId, { return handleRequest(Operation.UPDATE, tableId, {
id: breakRowIdField(id), id: breakRowIdField(id),
row: inputs, row: inputs,
includeSqlRelationships: IncludeRelationship.EXCLUDE,
}) })
} }
@ -65,6 +67,7 @@ export async function save(ctx: BBContext) {
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
return handleRequest(Operation.CREATE, tableId, { return handleRequest(Operation.CREATE, tableId, {
row: inputs, row: inputs,
includeSqlRelationships: IncludeRelationship.EXCLUDE,
}) })
} }
@ -78,7 +81,9 @@ export async function fetchView(ctx: BBContext) {
export async function fetch(ctx: BBContext) { export async function fetch(ctx: BBContext) {
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
return handleRequest(Operation.READ, tableId) return handleRequest(Operation.READ, tableId, {
includeSqlRelationships: IncludeRelationship.INCLUDE,
})
} }
export async function find(ctx: BBContext) { export async function find(ctx: BBContext) {
@ -86,6 +91,7 @@ export async function find(ctx: BBContext) {
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
const response = (await handleRequest(Operation.READ, tableId, { const response = (await handleRequest(Operation.READ, tableId, {
id: breakRowIdField(id), id: breakRowIdField(id),
includeSqlRelationships: IncludeRelationship.EXCLUDE,
})) as Row[] })) as Row[]
return response ? response[0] : response return response ? response[0] : response
} }
@ -95,6 +101,7 @@ export async function destroy(ctx: BBContext) {
const id = ctx.request.body._id const id = ctx.request.body._id
const { row } = (await handleRequest(Operation.DELETE, tableId, { const { row } = (await handleRequest(Operation.DELETE, tableId, {
id: breakRowIdField(id), id: breakRowIdField(id),
includeSqlRelationships: IncludeRelationship.EXCLUDE,
})) as { row: Row } })) as { row: Row }
return { response: { ok: true }, row } return { response: { ok: true }, row }
} }
@ -107,6 +114,7 @@ export async function bulkDestroy(ctx: BBContext) {
promises.push( promises.push(
handleRequest(Operation.DELETE, tableId, { handleRequest(Operation.DELETE, tableId, {
id: breakRowIdField(row._id), id: breakRowIdField(row._id),
includeSqlRelationships: IncludeRelationship.EXCLUDE,
}) })
) )
} }
@ -149,6 +157,7 @@ export async function search(ctx: BBContext) {
filters: query, filters: query,
sort, sort,
paginate: paginateObj as PaginationJson, paginate: paginateObj as PaginationJson,
includeSqlRelationships: IncludeRelationship.INCLUDE,
})) as Row[] })) as Row[]
let hasNextPage = false let hasNextPage = false
if (paginate && rows.length === limit) { if (paginate && rows.length === limit) {
@ -159,6 +168,7 @@ export async function search(ctx: BBContext) {
limit: 1, limit: 1,
page: bookmark * limit + 1, page: bookmark * limit + 1,
}, },
includeSqlRelationships: IncludeRelationship.INCLUDE,
})) as Row[] })) as Row[]
hasNextPage = nextRows.length > 0 hasNextPage = nextRows.length > 0
} }
@ -247,6 +257,7 @@ export async function fetchEnrichedRow(ctx: BBContext) {
const response = (await handleRequest(Operation.READ, tableId, { const response = (await handleRequest(Operation.READ, tableId, {
id, id,
datasource, datasource,
includeSqlRelationships: IncludeRelationship.INCLUDE,
})) as Row[] })) as Row[]
const table: Table = tables[tableName] const table: Table = tables[tableName]
const row = response[0] const row = response[0]
@ -274,6 +285,7 @@ export async function fetchEnrichedRow(ctx: BBContext) {
[primaryLink]: linkedIds, [primaryLink]: linkedIds,
}, },
}, },
includeSqlRelationships: IncludeRelationship.INCLUDE,
}) })
} }
return row return row

View file

@ -8,7 +8,7 @@ import {
foreignKeyStructure, foreignKeyStructure,
hasTypeChanged, hasTypeChanged,
} from "./utils" } from "./utils"
import { FieldTypes, RelationshipTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
import { makeExternalQuery } from "../../../integrations/base/query" import { makeExternalQuery } from "../../../integrations/base/query"
import { handleRequest } from "../row/external" import { handleRequest } from "../row/external"
import { events, context } from "@budibase/backend-core" import { events, context } from "@budibase/backend-core"
@ -22,6 +22,7 @@ import {
FieldSchema, FieldSchema,
BBContext, BBContext,
TableRequest, TableRequest,
RelationshipTypes,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
@ -146,7 +147,7 @@ function generateLinkSchema(
column: FieldSchema, column: FieldSchema,
table: Table, table: Table,
relatedTable: Table, relatedTable: Table,
type: string type: RelationshipTypes
) { ) {
if (!table.primary || !relatedTable.primary) { if (!table.primary || !relatedTable.primary) {
throw new Error("Unable to generate link schema, no primary keys") throw new Error("Unable to generate link schema, no primary keys")

View file

@ -13,7 +13,7 @@ beforeAll(async () => {
app = await config.init() app = await config.init()
table = await config.updateTable() table = await config.updateTable()
apiKey = await config.generateApiKey() apiKey = await config.generateApiKey()
makeRequest = generateMakeRequest(apiKey, setup) makeRequest = generateMakeRequest(apiKey)
}) })
afterAll(setup.afterAll) afterAll(setup.afterAll)

View file

@ -10,7 +10,7 @@ beforeAll(async () => {
await config.init() await config.init()
globalUser = await config.globalUser() globalUser = await config.globalUser()
apiKey = await config.generateApiKey(globalUser._id) apiKey = await config.generateApiKey(globalUser._id)
makeRequest = generateMakeRequest(apiKey, setup) makeRequest = generateMakeRequest(apiKey)
workerRequests.readGlobalUser.mockReturnValue(globalUser) workerRequests.readGlobalUser.mockReturnValue(globalUser)
}) })

View file

@ -1,13 +1,27 @@
import * as setup from "../../tests/utilities"
import { checkSlashesInUrl } from "../../../../utilities" import { checkSlashesInUrl } from "../../../../utilities"
import supertest from "supertest"
export function generateMakeRequest(apiKey: string, setup: any) { export type HttpMethod = "post" | "get" | "put" | "delete" | "patch"
const request = setup.getRequest()
const config = setup.getConfig() export type MakeRequestResponse = (
method: HttpMethod,
endpoint: string,
body?: any,
intAppId?: string
) => Promise<supertest.Response>
export function generateMakeRequest(
apiKey: string,
isInternal = false
): MakeRequestResponse {
const request = setup.getRequest()!
const config = setup.getConfig()!
return async ( return async (
method: string, method: HttpMethod,
endpoint: string, endpoint: string,
body?: any, body?: any,
intAppId: string = config.getAppId() intAppId: string | null = config.getAppId()
) => { ) => {
const extraHeaders: any = { const extraHeaders: any = {
"x-budibase-api-key": apiKey, "x-budibase-api-key": apiKey,
@ -15,9 +29,12 @@ export function generateMakeRequest(apiKey: string, setup: any) {
if (intAppId) { if (intAppId) {
extraHeaders["x-budibase-app-id"] = intAppId extraHeaders["x-budibase-app-id"] = intAppId
} }
const req = request[method](
checkSlashesInUrl(`/api/public/v1/${endpoint}`) const url = isInternal
).set(config.defaultHeaders(extraHeaders)) ? endpoint
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
const req = request[method](url).set(config.defaultHeaders(extraHeaders))
if (body) { if (body) {
req.send(body) req.send(body)
} }

View file

@ -7,7 +7,7 @@ import * as setup from "./utilities"
import { wipeDb } from "./utilities/TestFunctions" import { wipeDb } from "./utilities/TestFunctions"
describe("/cloud", () => { describe("/cloud", () => {
let request = setup.getRequest() let request = setup.getRequest()!
let config = setup.getConfig() let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(setup.afterAll)

View file

@ -1,4 +1,5 @@
import { objectStore, roles, constants } from "@budibase/backend-core" import { objectStore, roles, constants } from "@budibase/backend-core"
export { FieldType as FieldTypes, RelationshipTypes } from "@budibase/types"
export enum FilterTypes { export enum FilterTypes {
STRING = "string", STRING = "string",
@ -22,23 +23,6 @@ export const NoEmptyFilterStrings = [
FilterTypes.NOT_CONTAINS, FilterTypes.NOT_CONTAINS,
] ]
export enum FieldTypes {
STRING = "string",
BARCODEQR = "barcodeqr",
LONGFORM = "longform",
OPTIONS = "options",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
DATETIME = "datetime",
ATTACHMENT = "attachment",
LINK = "link",
FORMULA = "formula",
AUTO = "auto",
JSON = "json",
INTERNAL = "internal",
}
export const CanSwitchTypes = [ export const CanSwitchTypes = [
[exports.FieldTypes.JSON, exports.FieldTypes.ARRAY], [exports.FieldTypes.JSON, exports.FieldTypes.ARRAY],
[ [
@ -54,12 +38,6 @@ export const SwitchableTypes = CanSwitchTypes.reduce((prev, current) =>
prev ? prev.concat(current) : current prev ? prev.concat(current) : current
) )
export enum RelationshipTypes {
ONE_TO_MANY = "one-to-many",
MANY_TO_ONE = "many-to-one",
MANY_TO_MANY = "many-to-many",
}
export enum FormulaTypes { export enum FormulaTypes {
STATIC = "static", STATIC = "static",
DYNAMIC = "dynamic", DYNAMIC = "dynamic",

View file

@ -1,8 +1,4 @@
import { import { FieldTypes, AutoFieldSubTypes } from "../../constants"
FieldTypes,
AutoFieldSubTypes,
RelationshipTypes,
} from "../../constants"
import { importToRows } from "../../api/controllers/table/utils" import { importToRows } from "../../api/controllers/table/utils"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import LinkDocument from "../linkedRows/LinkDocument" import LinkDocument from "../linkedRows/LinkDocument"
@ -11,7 +7,7 @@ import { employeeImport } from "./employeeImport"
import { jobsImport } from "./jobsImport" import { jobsImport } from "./jobsImport"
import { expensesImport } from "./expensesImport" import { expensesImport } from "./expensesImport"
import { db as dbCore } from "@budibase/backend-core" import { db as dbCore } from "@budibase/backend-core"
import { Table, Row } from "@budibase/types" import { Table, Row, RelationshipTypes } from "@budibase/types"
export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs" export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
@ -190,7 +186,7 @@ export const DEFAULT_INVENTORY_TABLE_SCHEMA: Table = {
}, },
} }
export const DEFAULT_EMPLOYEE_TABLE_SCHEMA = { export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = {
_id: DEFAULT_EMPLOYEE_TABLE_ID, _id: DEFAULT_EMPLOYEE_TABLE_ID,
type: "internal", type: "internal",
views: {}, views: {},
@ -287,7 +283,7 @@ export const DEFAULT_EMPLOYEE_TABLE_SCHEMA = {
sortable: false, sortable: false,
}, },
"Badge Photo": { "Badge Photo": {
type: "attachment", type: FieldTypes.ATTACHMENT,
constraints: { constraints: {
type: FieldTypes.ARRAY, type: FieldTypes.ARRAY,
presence: false, presence: false,
@ -466,7 +462,7 @@ export const DEFAULT_JOBS_TABLE_SCHEMA: Table = {
// sortable: true, // sortable: true,
}, },
"Works End": { "Works End": {
type: "datetime", type: FieldTypes.DATETIME,
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -480,7 +476,7 @@ export const DEFAULT_JOBS_TABLE_SCHEMA: Table = {
ignoreTimezones: true, ignoreTimezones: true,
}, },
"Updated Price": { "Updated Price": {
type: "number", type: FieldTypes.NUMBER,
constraints: { constraints: {
type: "number", type: "number",
presence: false, presence: false,

View file

@ -1,13 +1,14 @@
import { IncludeDocs, getLinkDocuments } from "./linkUtils" import { IncludeDocs, getLinkDocuments } from "./linkUtils"
import { InternalTables, getUserMetadataParams } from "../utils" import { InternalTables, getUserMetadataParams } from "../utils"
import Sentry from "@sentry/node" import Sentry from "@sentry/node"
import { FieldTypes, RelationshipTypes } from "../../constants" import { FieldTypes } from "../../constants"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import LinkDocument from "./LinkDocument" import LinkDocument from "./LinkDocument"
import { import {
Database, Database,
FieldSchema, FieldSchema,
LinkDocumentValue, LinkDocumentValue,
RelationshipTypes,
Row, Row,
Table, Table,
} from "@budibase/types" } from "@budibase/types"

View file

@ -0,0 +1,752 @@
import {
generateMakeRequest,
MakeRequestResponse,
} from "../api/routes/public/tests/utils"
import * as setup from "../api/routes/tests/utilities"
import {
Datasource,
FieldType,
RelationshipTypes,
Row,
SourceName,
Table,
} from "@budibase/types"
import _ from "lodash"
import { generator } from "@budibase/backend-core/tests"
import { utils } from "@budibase/backend-core"
import { GenericContainer } from "testcontainers"
const config = setup.getConfig()!
jest.setTimeout(30000)
jest.unmock("pg")
describe("row api - postgres", () => {
let makeRequest: MakeRequestResponse,
postgresDatasource: Datasource,
primaryPostgresTable: Table,
auxPostgresTable: Table
let host: string
let port: number
beforeAll(async () => {
const container = await new GenericContainer("postgres")
.withExposedPorts(5432)
.withEnv("POSTGRES_PASSWORD", "password")
.start()
host = container.getContainerIpAddress()
port = container.getMappedPort(5432)
await config.init()
const apiKey = await config.generateApiKey()
makeRequest = generateMakeRequest(apiKey, true)
})
beforeEach(async () => {
postgresDatasource = await config.createDatasource({
datasource: {
type: "datasource",
source: SourceName.POSTGRES,
plus: true,
config: {
host,
port,
database: "postgres",
user: "postgres",
password: "password",
schema: "public",
ssl: false,
rejectUnauthorized: false,
ca: false,
},
},
})
auxPostgresTable = await config.createTable({
name: generator.word({ length: 10 }),
type: "external",
primary: ["id"],
schema: {
id: {
name: "id",
type: FieldType.AUTO,
constraints: {
presence: true,
},
},
title: {
name: "title",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
},
sourceId: postgresDatasource._id,
})
primaryPostgresTable = await config.createTable({
name: generator.word({ length: 10 }),
type: "external",
primary: ["id"],
schema: {
id: {
name: "id",
type: FieldType.AUTO,
constraints: {
presence: true,
},
},
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
description: {
name: "description",
type: FieldType.STRING,
},
value: {
name: "value",
type: FieldType.NUMBER,
},
linkedField: {
type: FieldType.LINK,
constraints: {
type: "array",
presence: false,
},
fieldName: "foreignField",
name: "linkedField",
relationshipType: RelationshipTypes.ONE_TO_MANY,
tableId: auxPostgresTable._id,
},
},
sourceId: postgresDatasource._id,
})
})
afterAll(async () => {
await config.end()
})
function generateRandomPrimaryRowData() {
return {
name: generator.name(),
description: generator.paragraph(),
value: generator.age(),
}
}
type PrimaryRowData = {
name: string
description: string
value: number
}
async function createPrimaryRow(opts: {
rowData: PrimaryRowData
createForeignRow?: boolean
}) {
let { rowData } = opts
let foreignRow: Row | undefined
if (opts?.createForeignRow) {
foreignRow = await config.createRow({
tableId: auxPostgresTable._id,
title: generator.name(),
})
rowData = {
...rowData,
[`fk_${auxPostgresTable.name}_foreignField`]: foreignRow.id,
}
}
const row = await config.createRow({
tableId: primaryPostgresTable._id,
...rowData,
})
return { row, foreignRow }
}
async function createDefaultPgTable() {
return await config.createTable({
name: generator.word({ length: 10 }),
type: "external",
primary: ["id"],
schema: {
id: {
name: "id",
type: FieldType.AUTO,
constraints: {
presence: true,
},
},
},
sourceId: postgresDatasource._id,
})
}
async function populatePrimaryRows(
count: number,
opts?: {
createForeignRow?: boolean
}
) {
return await Promise.all(
Array(count)
.fill({})
.map(async () => {
const rowData = generateRandomPrimaryRowData()
return {
rowData,
...(await createPrimaryRow({
rowData,
createForeignRow: opts?.createForeignRow,
})),
}
})
)
}
it("validate table schema", async () => {
const res = await makeRequest(
"get",
`/api/datasources/${postgresDatasource._id}`
)
expect(res.status).toBe(200)
expect(res.body).toEqual({
config: {
ca: false,
database: "postgres",
host,
password: "--secret-value--",
port,
rejectUnauthorized: false,
schema: "public",
ssl: false,
user: "postgres",
},
plus: true,
source: "POSTGRES",
type: "datasource",
_id: expect.any(String),
_rev: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
entities: expect.any(Object),
})
})
describe("POST /api/:tableId/rows", () => {
const createRow = (tableId: string | undefined, body: object) =>
makeRequest("post", `/api/${tableId}/rows`, body)
describe("given than no row exists", () => {
it("adding a new one persists it", async () => {
const newRow = generateRandomPrimaryRowData()
const res = await createRow(primaryPostgresTable._id, newRow)
expect(res.status).toBe(200)
const persistedRows = await config.getRows(primaryPostgresTable._id!)
expect(persistedRows).toHaveLength(1)
const expected = {
...res.body,
...newRow,
}
expect(persistedRows).toEqual([expect.objectContaining(expected)])
})
it("multiple rows can be persisted", async () => {
const numberOfRows = 10
const newRows = Array(numberOfRows).fill(generateRandomPrimaryRowData())
for (const newRow of newRows) {
const res = await createRow(primaryPostgresTable._id, newRow)
expect(res.status).toBe(200)
}
const persistedRows = await config.getRows(primaryPostgresTable._id!)
expect(persistedRows).toHaveLength(numberOfRows)
expect(persistedRows).toEqual(
expect.arrayContaining(newRows.map(expect.objectContaining))
)
})
})
})
describe("PATCH /api/:tableId/rows", () => {
const updateRow = (tableId: string | undefined, body: Row) =>
makeRequest("patch", `/api/${tableId}/rows`, body)
describe("given than a row exists", () => {
let row: Row
beforeEach(async () => {
let rowResponse = _.sample(await populatePrimaryRows(10))!
row = rowResponse.row
})
it("updating it persists it", async () => {
const newName = generator.name()
const newValue = generator.age()
const updatedRow = {
...row,
name: newName,
value: newValue,
}
const res = await updateRow(primaryPostgresTable._id, updatedRow)
expect(res.status).toBe(200)
expect(res.body).toEqual(updatedRow)
const persistedRow = await config.getRow(
primaryPostgresTable._id!,
row.id
)
expect(persistedRow).toEqual(
expect.objectContaining({
id: row.id,
name: newName,
value: newValue,
})
)
})
})
})
describe("DELETE /api/:tableId/rows", () => {
const deleteRow = (
tableId: string | undefined,
body: Row | { rows: Row[] }
) => makeRequest("delete", `/api/${tableId}/rows`, body)
describe("given than multiple row exist", () => {
const numberOfInitialRows = 5
let rows: Row[]
beforeEach(async () => {
rows = (await populatePrimaryRows(numberOfInitialRows)).map(x => x.row)
})
it("delete request removes it", async () => {
const row = _.sample(rows)!
const res = await deleteRow(primaryPostgresTable._id, row)
expect(res.status).toBe(200)
const persistedRows = await config.getRows(primaryPostgresTable._id!)
expect(persistedRows).toHaveLength(numberOfInitialRows - 1)
expect(row.id).toBeDefined()
expect(persistedRows).not.toContain(
expect.objectContaining({ _id: row.id })
)
})
it("multiple rows can be removed at once", async () => {
let rowsToDelete = _.sampleSize(rows, 3)!
const res = await deleteRow(primaryPostgresTable._id, {
rows: rowsToDelete,
})
expect(res.status).toBe(200)
const persistedRows = await config.getRows(primaryPostgresTable._id!)
expect(persistedRows).toHaveLength(numberOfInitialRows - 3)
for (const row of rowsToDelete) {
expect(persistedRows).not.toContain(
expect.objectContaining({ _id: row.id })
)
}
})
})
})
describe("GET /api/:tableId/rows/:rowId", () => {
const getRow = (tableId: string | undefined, rowId?: string | undefined) =>
makeRequest("get", `/api/${tableId}/rows/${rowId}`)
describe("given than a table have a single row", () => {
let rowData: PrimaryRowData, row: Row
beforeEach(async () => {
const [createdRow] = await populatePrimaryRows(1)
rowData = createdRow.rowData
row = createdRow.row
})
it("the row can be retrieved successfully", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(res.body).toEqual(expect.objectContaining(rowData))
})
})
describe("given than a table have a multiple rows", () => {
let rows: { row: Row; rowData: PrimaryRowData }[]
beforeEach(async () => {
rows = await populatePrimaryRows(10)
})
it("a single row can be retrieved successfully", async () => {
const { rowData, row } = _.sample(rows)!
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(res.body).toEqual(expect.objectContaining(rowData))
})
})
describe("given a row with relation data", () => {
let row: Row
beforeEach(async () => {
let [createdRow] = await populatePrimaryRows(1, {
createForeignRow: true,
})
row = createdRow.row
})
it("foreign key fields are not retrieved", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(res.body).toEqual({
...row,
_id: expect.any(String),
_rev: expect.any(String),
})
expect(res.body.foreignField).toBeUndefined()
})
})
})
describe("POST /api/:tableId/search", () => {
const search = (tableId: string | undefined, body?: object) =>
makeRequest("post", `/api/${tableId}/search`, body)
describe("search without parameters", () => {
describe("given than a table has no rows", () => {
it("search without query returns empty", async () => {
const res = await search(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body).toEqual({
rows: [],
bookmark: null,
hasNextPage: false,
})
})
})
describe("given than a table has multiple rows", () => {
const rowsCount = 6
let rows: {
row: Row
rowData: PrimaryRowData
}[]
beforeEach(async () => {
rows = await populatePrimaryRows(rowsCount)
})
it("search without query returns all of them", async () => {
const res = await search(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body).toEqual({
rows: expect.arrayContaining(
rows.map(r => expect.objectContaining(r.rowData))
),
bookmark: null,
hasNextPage: false,
})
expect(res.body.rows).toHaveLength(rowsCount)
})
})
describe("given than multiple tables have multiple rows", () => {
const rowsCount = 6
beforeEach(async () => {
const createRandomTableWithRows = async () =>
await config.createRow({
tableId: (await createDefaultPgTable())._id,
title: generator.name(),
})
await createRandomTableWithRows()
await createRandomTableWithRows()
await populatePrimaryRows(rowsCount)
await createRandomTableWithRows()
})
it("search only return the requested ones", async () => {
const res = await search(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body.rows).toHaveLength(rowsCount)
})
})
})
it("Querying by a string field returns the rows with field containing or starting by that value", async () => {
const name = generator.name()
const rowsToFilter = [
...Array(2).fill({
name,
description: generator.paragraph(),
value: generator.age(),
}),
...Array(2).fill({
name: `${name}${utils.newid()}`,
description: generator.paragraph(),
value: generator.age(),
}),
]
await populatePrimaryRows(3)
for (const row of rowsToFilter) {
await createPrimaryRow({
rowData: row,
})
}
await populatePrimaryRows(1)
const res = await search(primaryPostgresTable._id, {
query: {
string: {
name,
},
},
})
expect(res.status).toBe(200)
expect(res.body).toEqual({
rows: expect.arrayContaining(rowsToFilter.map(expect.objectContaining)),
bookmark: null,
hasNextPage: false,
})
expect(res.body.rows).toHaveLength(4)
})
it("Querying respects the limit fields", async () => {
await populatePrimaryRows(6)
const res = await search(primaryPostgresTable._id, {
limit: 2,
})
expect(res.status).toBe(200)
expect(res.body.rows).toHaveLength(2)
})
describe("sort", () => {
beforeEach(async () => {
const defaultValue = generateRandomPrimaryRowData()
await createPrimaryRow({
rowData: {
...defaultValue,
name: "d",
value: 3,
},
})
await createPrimaryRow({
rowData: { ...defaultValue, name: "aaa", value: 40 },
})
await createPrimaryRow({
rowData: { ...defaultValue, name: "ccccc", value: -5 },
})
await createPrimaryRow({
rowData: { ...defaultValue, name: "bb", value: 0 },
})
})
it("Querying respects the sort order when sorting ascending by a string value", async () => {
const res = await search(primaryPostgresTable._id, {
sort: "name",
sortOrder: "ascending",
sortType: "string",
})
expect(res.status).toBe(200)
expect(res.body.rows).toEqual([
expect.objectContaining({ name: "aaa" }),
expect.objectContaining({ name: "bb" }),
expect.objectContaining({ name: "ccccc" }),
expect.objectContaining({ name: "d" }),
])
})
it("Querying respects the sort order when sorting descending by a string value", async () => {
const res = await search(primaryPostgresTable._id, {
sort: "name",
sortOrder: "descending",
sortType: "string",
})
expect(res.status).toBe(200)
expect(res.body.rows).toEqual([
expect.objectContaining({ name: "d" }),
expect.objectContaining({ name: "ccccc" }),
expect.objectContaining({ name: "bb" }),
expect.objectContaining({ name: "aaa" }),
])
})
it("Querying respects the sort order when sorting ascending by a numeric value", async () => {
const res = await search(primaryPostgresTable._id, {
sort: "value",
sortOrder: "ascending",
sortType: "number",
})
expect(res.status).toBe(200)
expect(res.body.rows).toEqual([
expect.objectContaining({ value: -5 }),
expect.objectContaining({ value: 0 }),
expect.objectContaining({ value: 3 }),
expect.objectContaining({ value: 40 }),
])
})
it("Querying respects the sort order when sorting descending by a numeric value", async () => {
const res = await search(primaryPostgresTable._id, {
sort: "value",
sortOrder: "descending",
sortType: "number",
})
expect(res.status).toBe(200)
expect(res.body.rows).toEqual([
expect.objectContaining({ value: 40 }),
expect.objectContaining({ value: 3 }),
expect.objectContaining({ value: 0 }),
expect.objectContaining({ value: -5 }),
])
})
})
})
describe("GET /api/:tableId/:rowId/enrich", () => {
const getAll = (tableId: string | undefined, rowId: string | undefined) =>
makeRequest("get", `/api/${tableId}/${rowId}/enrich`)
describe("given a row with relation data", () => {
let row: Row, foreignRow: Row | undefined
beforeEach(async () => {
const rowsInfo = await createPrimaryRow({
rowData: generateRandomPrimaryRowData(),
createForeignRow: true,
})
row = rowsInfo.row
foreignRow = rowsInfo.foreignRow
})
it("enrich populates the foreign field", async () => {
const res = await getAll(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(foreignRow).toBeDefined()
expect(res.body).toEqual({
...row,
linkedField: [
{
...foreignRow,
},
],
})
})
})
})
describe("GET /api/:tableId/rows", () => {
const getAll = (tableId: string | undefined) =>
makeRequest("get", `/api/${tableId}/rows`)
describe("given a table with no rows", () => {
it("get request returns empty", async () => {
const res = await getAll(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body).toHaveLength(0)
})
})
describe("given a table with multiple rows", () => {
const rowsCount = 6
let rows: {
row: Row
foreignRow: Row | undefined
rowData: PrimaryRowData
}[]
beforeEach(async () => {
rows = await populatePrimaryRows(rowsCount)
})
it("get request returns all of them", async () => {
const res = await getAll(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body).toHaveLength(rowsCount)
expect(res.body).toEqual(
expect.arrayContaining(
rows.map(r => expect.objectContaining(r.rowData))
)
)
})
})
describe("given multiple tables with multiple rows", () => {
const rowsCount = 6
beforeEach(async () => {
const createRandomTableWithRows = async () =>
await config.createRow({
tableId: (await createDefaultPgTable())._id,
title: generator.name(),
})
await createRandomTableWithRows()
await populatePrimaryRows(rowsCount)
await createRandomTableWithRows()
})
it("get returns the requested ones", async () => {
const res = await getAll(primaryPostgresTable._id)
expect(res.status).toBe(200)
expect(res.body).toHaveLength(rowsCount)
})
})
})
})

View file

@ -90,10 +90,15 @@ function parseFilters(filters: SearchFilters | undefined): SearchFilters {
function generateSelectStatement( function generateSelectStatement(
json: QueryJson, json: QueryJson,
knex: Knex knex: Knex
): (string | Knex.Raw)[] { ): (string | Knex.Raw)[] | "*" {
const { resource, meta } = json const { resource, meta } = json
if (!resource) {
return "*"
}
const schema = meta?.table?.schema const schema = meta?.table?.schema
return resource!.fields.map(field => { return resource.fields.map(field => {
const fieldNames = field.split(/\./g) const fieldNames = field.split(/\./g)
const tableName = fieldNames[0] const tableName = fieldNames[0]
const columnName = fieldNames[1] const columnName = fieldNames[1]
@ -392,11 +397,14 @@ class InternalBuilder {
delete parsedBody[key] delete parsedBody[key]
} }
} }
// mysql can't use returning // mysql can't use returning
if (opts.disableReturning) { if (opts.disableReturning) {
return query.insert(parsedBody) return query.insert(parsedBody)
} else { } else {
return query.insert(parsedBody).returning("*") return query
.insert(parsedBody)
.returning(generateSelectStatement(json, knex))
} }
} }
@ -481,7 +489,9 @@ class InternalBuilder {
if (opts.disableReturning) { if (opts.disableReturning) {
return query.update(parsedBody) return query.update(parsedBody)
} else { } else {
return query.update(parsedBody).returning("*") return query
.update(parsedBody)
.returning(generateSelectStatement(json, knex))
} }
} }
@ -496,7 +506,7 @@ class InternalBuilder {
if (opts.disableReturning) { if (opts.disableReturning) {
return query.delete() return query.delete()
} else { } else {
return query.delete().returning("*") return query.delete().returning(generateSelectStatement(json, knex))
} }
} }
} }

View file

@ -247,7 +247,7 @@ class OracleIntegration extends Sql implements DatasourcePlus {
) )
} }
private internalConvertType(column: OracleColumn): { type: string } { private internalConvertType(column: OracleColumn): { type: FieldTypes } {
if (this.isBooleanType(column)) { if (this.isBooleanType(column)) {
return { type: FieldTypes.BOOLEAN } return { type: FieldTypes.BOOLEAN }
} }

View file

@ -39,8 +39,15 @@ import { cleanup } from "../../utilities/fileSystem"
import newid from "../../db/newid" import newid from "../../db/newid"
import { generateUserMetadataID } from "../../db/utils" import { generateUserMetadataID } from "../../db/utils"
import { startup } from "../../startup" import { startup } from "../../startup"
import { AuthToken, Database } from "@budibase/types" import supertest from "supertest"
const supertest = require("supertest") import {
AuthToken,
Database,
Datasource,
Row,
SourceName,
Table,
} from "@budibase/types"
type DefaultUserValues = { type DefaultUserValues = {
globalUserId: string globalUserId: string
@ -52,7 +59,7 @@ type DefaultUserValues = {
class TestConfiguration { class TestConfiguration {
server: any server: any
request: any request: supertest.SuperTest<supertest.Test> | undefined
started: boolean started: boolean
appId: string | null appId: string | null
allApps: any[] allApps: any[]
@ -197,7 +204,7 @@ class TestConfiguration {
// UTILS // UTILS
async _req(body: any, params: any, controlFunc: any) { _req(body: any, params: any, controlFunc: any) {
// create a fake request ctx // create a fake request ctx
const request: any = {} const request: any = {}
const appId = this.appId const appId = this.appId
@ -360,6 +367,7 @@ class TestConfiguration {
[constants.Header.CSRF_TOKEN]: this.defaultUserValues.csrfToken, [constants.Header.CSRF_TOKEN]: this.defaultUserValues.csrfToken,
...extras, ...extras,
} }
if (this.appId) { if (this.appId) {
headers[constants.Header.APP_ID] = this.appId headers[constants.Header.APP_ID] = this.appId
} }
@ -464,13 +472,13 @@ class TestConfiguration {
// TABLE // TABLE
async updateTable(config?: any) { async updateTable(config?: any): Promise<Table> {
config = config || basicTable() config = config || basicTable()
this.table = await this._req(config, null, controllers.table.save) this.table = await this._req(config, null, controllers.table.save)
return this.table return this.table
} }
async createTable(config?: any) { async createTable(config?: Table) {
if (config != null && config._id) { if (config != null && config._id) {
delete config._id delete config._id
} }
@ -514,7 +522,7 @@ class TestConfiguration {
// ROW // ROW
async createRow(config: any = null) { async createRow(config?: Row): Promise<Row> {
if (!this.table) { if (!this.table) {
throw "Test requires table to be configured." throw "Test requires table to be configured."
} }
@ -523,7 +531,7 @@ class TestConfiguration {
return this._req(config, { tableId }, controllers.row.save) return this._req(config, { tableId }, controllers.row.save)
} }
async getRow(tableId: string, rowId: string) { async getRow(tableId: string, rowId: string): Promise<Row> {
return this._req(null, { tableId, rowId }, controllers.row.find) return this._req(null, { tableId, rowId }, controllers.row.find)
} }
@ -605,7 +613,9 @@ class TestConfiguration {
// DATASOURCE // DATASOURCE
async createDatasource(config?: any) { async createDatasource(config?: {
datasource: Datasource
}): Promise<Datasource> {
config = config || basicDatasource() config = config || basicDatasource()
const response = await this._req(config, null, controllers.datasource.save) const response = await this._req(config, null, controllers.datasource.save)
this.datasource = response.datasource this.datasource = response.datasource
@ -626,7 +636,7 @@ class TestConfiguration {
return this.createDatasource({ return this.createDatasource({
datasource: { datasource: {
...basicDatasource().datasource, ...basicDatasource().datasource,
source: "REST", source: SourceName.REST,
config: cfg || {}, config: cfg || {},
}, },
}) })
@ -635,7 +645,7 @@ class TestConfiguration {
async dynamicVariableDatasource() { async dynamicVariableDatasource() {
let datasource = await this.restDatasource() let datasource = await this.restDatasource()
const basedOnQuery = await this.createQuery({ const basedOnQuery = await this.createQuery({
...basicQuery(datasource._id), ...basicQuery(datasource._id!),
fields: { fields: {
path: "www.google.com", path: "www.google.com",
}, },
@ -663,7 +673,7 @@ class TestConfiguration {
datasource: any, datasource: any,
fields: any, fields: any,
params: any, params: any,
verb: string verb?: string
) { ) {
return request return request
.post(`/api/queries/preview`) .post(`/api/queries/preview`)

View file

@ -7,6 +7,8 @@ import {
Automation, Automation,
AutomationActionStepId, AutomationActionStepId,
AutomationTriggerStepId, AutomationTriggerStepId,
Datasource,
SourceName,
} from "@budibase/types" } from "@budibase/types"
const { v4: uuidv4 } = require("uuid") const { v4: uuidv4 } = require("uuid")
@ -207,12 +209,12 @@ export function basicRole() {
} }
} }
export function basicDatasource() { export function basicDatasource(): { datasource: Datasource } {
return { return {
datasource: { datasource: {
type: "datasource", type: "datasource",
name: "Test", name: "Test",
source: "POSTGRES", source: SourceName.POSTGRES,
config: {}, config: {},
}, },
} }

View file

@ -14,6 +14,7 @@ export enum FieldType {
AUTO = "auto", AUTO = "auto",
JSON = "json", JSON = "json",
INTERNAL = "internal", INTERNAL = "internal",
BARCODEQR = "barcodeqr",
} }
export interface RowAttachment { export interface RowAttachment {

View file

@ -1,16 +1,22 @@
import { Document } from "../document" import { Document } from "../document"
import { View } from "./view" import { View } from "./view"
import { RenameColumn } from "../../sdk" import { RenameColumn } from "../../sdk"
import { FieldType } from "./row"
export enum RelationshipTypes {
ONE_TO_MANY = "one-to-many",
MANY_TO_ONE = "many-to-one",
MANY_TO_MANY = "many-to-many",
}
export interface FieldSchema { export interface FieldSchema {
// TODO: replace with field types enum when done type: FieldType
type: string
externalType?: string externalType?: string
fieldName?: string fieldName?: string
name: string name: string
sortable?: boolean sortable?: boolean
tableId?: string tableId?: string
relationshipType?: string relationshipType?: RelationshipTypes
through?: string through?: string
foreignKey?: string foreignKey?: string
icon?: string icon?: string