From 7cceb04ca2d2fc5a7dd5eb776750ba6d985daad1 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 2 Feb 2024 11:19:05 +0000 Subject: [PATCH 1/8] Basic Postgres and Mongo query testcases. --- .../api/routes/tests/queries/mongodb.spec.ts | 109 ++++++++++++++ .../api/routes/tests/queries/postgres.spec.ts | 139 ++++++++++++++++++ .../tests/{ => queries}/query.seq.spec.ts | 6 +- .../server/src/api/routes/tests/row.spec.ts | 36 +---- .../src/integration-test/mongodb.spec.ts | 0 .../src/integration-test/postgres.spec.ts | 12 +- .../src/integrations/tests/utils/index.ts | 14 +- .../src/integrations/tests/utils/mongodb.ts | 44 ++++++ .../src/integrations/tests/utils/postgres.ts | 70 ++++----- .../server/src/tests/utilities/api/index.ts | 3 + .../server/src/tests/utilities/api/query.ts | 36 +++++ 11 files changed, 385 insertions(+), 84 deletions(-) create mode 100644 packages/server/src/api/routes/tests/queries/mongodb.spec.ts create mode 100644 packages/server/src/api/routes/tests/queries/postgres.spec.ts rename packages/server/src/api/routes/tests/{ => queries}/query.seq.spec.ts (99%) create mode 100644 packages/server/src/integration-test/mongodb.spec.ts create mode 100644 packages/server/src/integrations/tests/utils/mongodb.ts create mode 100644 packages/server/src/tests/utilities/api/query.ts diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts new file mode 100644 index 0000000000..d9b29cb069 --- /dev/null +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -0,0 +1,109 @@ +import { Datasource, Query } from "@budibase/types" +import * as setup from "../utilities" +import { databaseTestProviders } from "../../../../integrations/tests/utils" +import { MongoClient } from "mongodb" + +jest.unmock("mongodb") +jest.setTimeout(3000) + +describe("/queries", () => { + let request = setup.getRequest() + let config = setup.getConfig() + let datasource: Datasource + + async function createQuery(query: Partial): Promise { + const defaultQuery: Query = { + datasourceId: datasource._id!, + name: "New Query", + parameters: [], + fields: {}, + schema: {}, + queryVerb: "read", + transformer: "return data", + readable: true, + } + + const res = await request + .post(`/api/queries`) + .set(config.defaultHeaders()) + .send({ ...defaultQuery, ...query }) + .expect("Content-Type", /json/) + + if (res.status !== 200) { + throw new Error(JSON.stringify(res.body)) + } + + return res.body as Query + } + + afterAll(async () => { + await databaseTestProviders.mongodb.stop() + setup.afterAll() + }) + + beforeAll(async () => { + await config.init() + datasource = await config.api.datasource.create( + await databaseTestProviders.mongodb.datasource() + ) + }) + + beforeEach(async () => { + const ds = await databaseTestProviders.mongodb.datasource() + const client = new MongoClient(ds.config!.connectionString) + await client.connect() + + const db = client.db(ds.config!.db) + const collection = db.collection("test_table") + await collection.insertMany([ + { name: "one" }, + { name: "two" }, + { name: "three" }, + { name: "four" }, + { name: "five" }, + ]) + await client.close() + }) + + afterEach(async () => { + const ds = await databaseTestProviders.mongodb.datasource() + const client = new MongoClient(ds.config!.connectionString) + await client.connect() + const db = client.db(ds.config!.db) + await db.collection("test_table").drop() + await client.close() + }) + + it("should execute a query", async () => { + const query = await createQuery({ + fields: { + json: "{}", + extra: { + actionType: "count", + collection: "test_table", + }, + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([{ value: 5 }]) + }) + + it("should execute a query with a transformer", async () => { + const query = await createQuery({ + fields: { + json: "{}", + extra: { + actionType: "count", + collection: "test_table", + }, + }, + transformer: "return data + 1", + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([{ value: 6 }]) + }) +}) diff --git a/packages/server/src/api/routes/tests/queries/postgres.spec.ts b/packages/server/src/api/routes/tests/queries/postgres.spec.ts new file mode 100644 index 0000000000..4e7f6bffb2 --- /dev/null +++ b/packages/server/src/api/routes/tests/queries/postgres.spec.ts @@ -0,0 +1,139 @@ +import { Datasource, Query } from "@budibase/types" +import * as setup from "../utilities" +import { databaseTestProviders } from "../../../../integrations/tests/utils" +import { Client } from "pg" + +jest.unmock("pg") + +const createTableSQL = ` +CREATE TABLE test_table ( + id serial PRIMARY KEY, + name VARCHAR ( 50 ) NOT NULL +); +` + +const insertSQL = ` +INSERT INTO test_table (name) VALUES ('one'); +INSERT INTO test_table (name) VALUES ('two'); +INSERT INTO test_table (name) VALUES ('three'); +INSERT INTO test_table (name) VALUES ('four'); +INSERT INTO test_table (name) VALUES ('five'); +` + +const dropTableSQL = ` +DROP TABLE test_table; +` + +describe("/queries", () => { + let request = setup.getRequest() + let config = setup.getConfig() + let datasource: Datasource + + async function createQuery(query: Partial): Promise { + const defaultQuery: Query = { + datasourceId: datasource._id!, + name: "New Query", + parameters: [], + fields: {}, + schema: {}, + queryVerb: "read", + transformer: "return data", + readable: true, + } + + const res = await request + .post(`/api/queries`) + .set(config.defaultHeaders()) + .send({ ...defaultQuery, ...query }) + .expect("Content-Type", /json/) + + if (res.status !== 200) { + throw new Error(JSON.stringify(res.body)) + } + + return res.body as Query + } + + afterAll(async () => { + await databaseTestProviders.postgres.stop() + setup.afterAll() + }) + + beforeAll(async () => { + await config.init() + datasource = await config.api.datasource.create( + await databaseTestProviders.postgres.datasource() + ) + }) + + beforeEach(async () => { + const ds = await databaseTestProviders.postgres.datasource() + const client = new Client(ds.config!) + await client.connect() + await client.query(createTableSQL) + await client.query(insertSQL) + await client.end() + }) + + afterEach(async () => { + const ds = await databaseTestProviders.postgres.datasource() + const client = new Client(ds.config!) + await client.connect() + await client.query(dropTableSQL) + await client.end() + }) + + it("should execute a query", async () => { + const query = await createQuery({ + fields: { + sql: "SELECT * FROM test_table ORDER BY id", + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + id: 1, + name: "one", + }, + { + id: 2, + name: "two", + }, + { + id: 3, + name: "three", + }, + { + id: 4, + name: "four", + }, + { + id: 5, + name: "five", + }, + ]) + }) + + it("should be able to transform a query", async () => { + const query = await createQuery({ + fields: { + sql: "SELECT * FROM test_table WHERE id = 1", + }, + transformer: ` + data[0].id = data[0].id + 1; + return data; + `, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + id: 2, + name: "one", + }, + ]) + }) +}) diff --git a/packages/server/src/api/routes/tests/query.seq.spec.ts b/packages/server/src/api/routes/tests/queries/query.seq.spec.ts similarity index 99% rename from packages/server/src/api/routes/tests/query.seq.spec.ts rename to packages/server/src/api/routes/tests/queries/query.seq.spec.ts index 2790a9d8bf..ba41ba3d16 100644 --- a/packages/server/src/api/routes/tests/query.seq.spec.ts +++ b/packages/server/src/api/routes/tests/queries/query.seq.spec.ts @@ -16,9 +16,9 @@ jest.mock("@budibase/backend-core", () => { }, } }) -import * as setup from "./utilities" -import { checkBuilderEndpoint } from "./utilities/TestFunctions" -import { checkCacheForDynamicVariable } from "../../../threads/utils" +import * as setup from "../utilities" +import { checkBuilderEndpoint } from "../utilities/TestFunctions" +import { checkCacheForDynamicVariable } from "../../../../threads/utils" const { basicQuery, basicDatasource } = setup.structures import { events, db as dbCore } from "@budibase/backend-core" diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index f481fa8068..637033c1d0 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -12,7 +12,6 @@ import { FieldTypeSubtypes, FormulaType, INTERNAL_TABLE_SOURCE_ID, - MonthlyQuotaName, PermissionLevel, QuotaUsageType, RelationshipType, @@ -53,7 +52,7 @@ describe.each([ afterAll(async () => { if (dsProvider) { - await dsProvider.stopContainer() + await dsProvider.stop() } setup.afterAll() }) @@ -63,7 +62,7 @@ describe.each([ if (dsProvider) { await config.createDatasource({ - datasource: await dsProvider.getDsConfig(), + datasource: await dsProvider.datasource(), }) } }) @@ -117,16 +116,6 @@ describe.each([ return total } - const getQueryUsage = async () => { - const { total } = await config.doInContext(null, () => - quotas.getCurrentUsageValues( - QuotaUsageType.MONTHLY, - MonthlyQuotaName.QUERIES - ) - ) - return total - } - const assertRowUsage = async (expected: number) => { const usage = await getRowUsage() expect(usage).toBe(expected) @@ -162,7 +151,6 @@ describe.each([ describe("save, load, update", () => { it("returns a success message when the row is created", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await request .post(`/api/${tableId}/rows`) @@ -180,7 +168,6 @@ describe.each([ it("Increment row autoId per create row request", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const tableConfig = generateTableConfig() const newTable = await createTable( @@ -231,7 +218,6 @@ describe.each([ it("updates a row successfully", async () => { const existing = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.save(tableId, { _id: existing._id, @@ -246,7 +232,6 @@ describe.each([ it("should load a row", async () => { const existing = await config.createRow() - const queryUsage = await getQueryUsage() const res = await config.api.row.get(tableId, existing._id!) @@ -268,7 +253,6 @@ describe.each([ } const firstRow = await config.createRow({ tableId }) await config.createRow(newRow) - const queryUsage = await getQueryUsage() const res = await config.api.row.fetch(tableId) @@ -279,7 +263,6 @@ describe.each([ it("load should return 404 when row does not exist", async () => { await config.createRow() - const queryUsage = await getQueryUsage() await config.api.row.get(tableId, "1234567", { expectStatus: 404, @@ -530,7 +513,6 @@ describe.each([ const existing = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const row = await config.api.row.patch(table._id!, { _id: existing._id!, @@ -552,7 +534,6 @@ describe.each([ it("should throw an error when given improper types", async () => { const existing = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() await config.api.row.patch( table._id!, @@ -650,7 +631,6 @@ describe.each([ it("should be able to delete a row", async () => { const createdRow = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.delete(table._id!, [createdRow]) expect(res.body[0]._id).toEqual(createdRow._id) @@ -666,7 +646,6 @@ describe.each([ it("should return no errors on valid row", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.validate(table._id!, { name: "ivan" }) @@ -677,7 +656,6 @@ describe.each([ it("should errors on invalid row", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.validate(table._id!, { name: 1 }) @@ -703,7 +681,6 @@ describe.each([ const row1 = await config.createRow() const row2 = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.delete(table._id!, [row1, row2]) @@ -719,7 +696,6 @@ describe.each([ config.createRow(), ]) const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.delete(table._id!, [ row1, @@ -735,7 +711,6 @@ describe.each([ it("should accept a valid row object and delete the row", async () => { const row1 = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.delete(table._id!, row1) @@ -746,7 +721,6 @@ describe.each([ it("Should ignore malformed/invalid delete requests", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.row.delete( table._id!, @@ -782,7 +756,6 @@ describe.each([ it("should be able to fetch tables contents via 'view'", async () => { const row = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.legacyView.get(table._id!) expect(res.body.length).toEqual(1) @@ -792,7 +765,6 @@ describe.each([ it("should throw an error if view doesn't exist", async () => { const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() await config.api.legacyView.get("derp", { expectStatus: 404 }) @@ -808,7 +780,6 @@ describe.each([ }) const row = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() const res = await config.api.legacyView.get(view.name) expect(res.body.length).toEqual(1) @@ -864,7 +835,6 @@ describe.each([ } ) const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() // test basic enrichment const resBasic = await config.api.row.get( @@ -1100,7 +1070,6 @@ describe.each([ const createdRow = await config.createRow() const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() await config.api.row.delete(view.id, [createdRow]) @@ -1127,7 +1096,6 @@ describe.each([ config.createRow(), ]) const rowUsage = await getRowUsage() - const queryUsage = await getQueryUsage() await config.api.row.delete(view.id, [rows[0], rows[2]]) diff --git a/packages/server/src/integration-test/mongodb.spec.ts b/packages/server/src/integration-test/mongodb.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/server/src/integration-test/postgres.spec.ts b/packages/server/src/integration-test/postgres.spec.ts index 600566c813..0031fe1136 100644 --- a/packages/server/src/integration-test/postgres.spec.ts +++ b/packages/server/src/integration-test/postgres.spec.ts @@ -41,12 +41,12 @@ describe("postgres integrations", () => { makeRequest = generateMakeRequest(apiKey, true) postgresDatasource = await config.api.datasource.create( - await databaseTestProviders.postgres.getDsConfig() + await databaseTestProviders.postgres.datasource() ) }) afterAll(async () => { - await databaseTestProviders.postgres.stopContainer() + await databaseTestProviders.postgres.stop() }) beforeEach(async () => { @@ -1041,14 +1041,14 @@ describe("postgres integrations", () => { describe("POST /api/datasources/verify", () => { it("should be able to verify the connection", async () => { const response = await config.api.datasource.verify({ - datasource: await databaseTestProviders.postgres.getDsConfig(), + datasource: await databaseTestProviders.postgres.datasource(), }) expect(response.status).toBe(200) expect(response.body.connected).toBe(true) }) it("should state an invalid datasource cannot connect", async () => { - const dbConfig = await databaseTestProviders.postgres.getDsConfig() + const dbConfig = await databaseTestProviders.postgres.datasource() const response = await config.api.datasource.verify({ datasource: { ...dbConfig, @@ -1082,7 +1082,7 @@ describe("postgres integrations", () => { beforeEach(async () => { client = new Client( - (await databaseTestProviders.postgres.getDsConfig()).config! + (await databaseTestProviders.postgres.datasource()).config! ) await client.connect() }) @@ -1125,7 +1125,7 @@ describe("postgres integrations", () => { schema2 = "test-2" beforeAll(async () => { - const dsConfig = await databaseTestProviders.postgres.getDsConfig() + const dsConfig = await databaseTestProviders.postgres.datasource() const dbConfig = dsConfig.config! client = new Client(dbConfig) diff --git a/packages/server/src/integrations/tests/utils/index.ts b/packages/server/src/integrations/tests/utils/index.ts index a28141db08..77fb5d7128 100644 --- a/packages/server/src/integrations/tests/utils/index.ts +++ b/packages/server/src/integrations/tests/utils/index.ts @@ -1,14 +1,16 @@ jest.unmock("pg") import { Datasource } from "@budibase/types" -import * as pg from "./postgres" +import * as postgres from "./postgres" +import * as mongodb from "./mongodb" +import { StartedTestContainer } from "testcontainers" jest.setTimeout(30000) -export interface DatabasePlusTestProvider { - getDsConfig(): Promise +export interface DatabaseProvider { + start(): Promise + stop(): Promise + datasource(): Promise } -export const databaseTestProviders = { - postgres: pg, -} +export const databaseTestProviders = { postgres, mongodb } diff --git a/packages/server/src/integrations/tests/utils/mongodb.ts b/packages/server/src/integrations/tests/utils/mongodb.ts new file mode 100644 index 0000000000..e0a7fa4a0c --- /dev/null +++ b/packages/server/src/integrations/tests/utils/mongodb.ts @@ -0,0 +1,44 @@ +import { Datasource, SourceName } from "@budibase/types" +import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" + +let container: StartedTestContainer | undefined + +export async function start(): Promise { + if (!container) { + container = await new GenericContainer("mongo:7.0-jammy") + .withExposedPorts(27017) + .withEnvironment({ + MONGO_INITDB_ROOT_USERNAME: "mongo", + MONGO_INITDB_ROOT_PASSWORD: "password", + }) + .withWaitStrategy( + Wait.forSuccessfulCommand( + `mongosh --eval "db.version()"` + ).withStartupTimeout(10000) + ) + .start() + } + return container +} + +export async function datasource(): Promise { + const container = await start() + const host = container.getHost() + const port = container.getMappedPort(27017) + return { + type: "datasource", + source: SourceName.MONGODB, + plus: false, + config: { + connectionString: `mongodb://mongo:password@${host}:${port}`, + db: "mongo", + }, + } +} + +export async function stop() { + if (container) { + await container.stop() + container = undefined + } +} diff --git a/packages/server/src/integrations/tests/utils/postgres.ts b/packages/server/src/integrations/tests/utils/postgres.ts index 8e66ef02d6..fc283ff996 100644 --- a/packages/server/src/integrations/tests/utils/postgres.ts +++ b/packages/server/src/integrations/tests/utils/postgres.ts @@ -3,45 +3,45 @@ import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" let container: StartedTestContainer | undefined -export async function getDsConfig(): Promise { - try { - if (!container) { - container = await new GenericContainer("postgres:16.1-bullseye") - .withExposedPorts(5432) - .withEnvironment({ POSTGRES_PASSWORD: "password" }) - .withWaitStrategy( - Wait.forLogMessage( - "database system is ready to accept connections", - 2 - ) - ) - .start() - } - const host = container.getHost() - const port = container.getMappedPort(5432) +export async function start(): Promise { + if (!container) { + container = await new GenericContainer("postgres:16.1-bullseye") + .withExposedPorts(5432) + .withEnvironment({ POSTGRES_PASSWORD: "password" }) + .withWaitStrategy( + Wait.forSuccessfulCommand( + "pg_isready -h localhost -p 5432" + ).withStartupTimeout(10000) + ) + .start() + } + return container +} - return { - type: "datasource_plus", - source: SourceName.POSTGRES, - plus: true, - config: { - host, - port, - database: "postgres", - user: "postgres", - password: "password", - schema: "public", - ssl: false, - rejectUnauthorized: false, - ca: false, - }, - } - } catch (err) { - throw new Error("**UNABLE TO CREATE TO POSTGRES CONTAINER**") +export async function datasource(): Promise { + const container = await start() + const host = container.getHost() + const port = container.getMappedPort(5432) + + return { + type: "datasource_plus", + source: SourceName.POSTGRES, + plus: true, + config: { + host, + port, + database: "postgres", + user: "postgres", + password: "password", + schema: "public", + ssl: false, + rejectUnauthorized: false, + ca: false, + }, } } -export async function stopContainer() { +export async function stop() { if (container) { await container.stop() container = undefined diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index 20b96f7a99..fdcec3098d 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -10,6 +10,7 @@ import { ApplicationAPI } from "./application" import { BackupAPI } from "./backup" import { AttachmentAPI } from "./attachment" import { UserAPI } from "./user" +import { QueryAPI } from "./query" export default class API { table: TableAPI @@ -23,6 +24,7 @@ export default class API { backup: BackupAPI attachment: AttachmentAPI user: UserAPI + query: QueryAPI constructor(config: TestConfiguration) { this.table = new TableAPI(config) @@ -36,5 +38,6 @@ export default class API { this.backup = new BackupAPI(config) this.attachment = new AttachmentAPI(config) this.user = new UserAPI(config) + this.query = new QueryAPI(config) } } diff --git a/packages/server/src/tests/utilities/api/query.ts b/packages/server/src/tests/utilities/api/query.ts new file mode 100644 index 0000000000..98ea91c60f --- /dev/null +++ b/packages/server/src/tests/utilities/api/query.ts @@ -0,0 +1,36 @@ +import TestConfiguration from "../TestConfiguration" +import { Query } from "@budibase/types" +import { TestAPI } from "./base" + +export class QueryAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + create = async (body: Query): Promise => { + const res = await this.request + .post(`/api/queries`) + .set(this.config.defaultHeaders()) + .send(body) + .expect("Content-Type", /json/) + + if (res.status !== 200) { + throw new Error(JSON.stringify(res.body)) + } + + return res.body as Query + } + + execute = async (queryId: string): Promise<{ data: any }> => { + const res = await this.request + .post(`/api/v2/queries/${queryId}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + + if (res.status !== 200) { + throw new Error(JSON.stringify(res.body)) + } + + return res.body + } +} From bb1c5c93d2b4305d354e4b053c1d66d4b34e5ec8 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 11:45:50 +0000 Subject: [PATCH 2/8] Remove shorter timeout on MongoDB tests. --- packages/server/src/api/routes/tests/queries/mongodb.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts index d9b29cb069..cb4ddd9ee1 100644 --- a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -4,7 +4,6 @@ import { databaseTestProviders } from "../../../../integrations/tests/utils" import { MongoClient } from "mongodb" jest.unmock("mongodb") -jest.setTimeout(3000) describe("/queries", () => { let request = setup.getRequest() From 871e1f3806b99e91b8691dfdda18a9cf24772455 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 11:54:33 +0000 Subject: [PATCH 3/8] Remove empty file. --- packages/server/src/integration-test/mongodb.spec.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/server/src/integration-test/mongodb.spec.ts diff --git a/packages/server/src/integration-test/mongodb.spec.ts b/packages/server/src/integration-test/mongodb.spec.ts deleted file mode 100644 index e69de29bb2..0000000000 From 1573242031469bae099aa03d49218f8b8f0b739c Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 14:26:19 +0000 Subject: [PATCH 4/8] Respond to PR feedback. --- .../api/routes/tests/queries/mongodb.spec.ts | 14 +-------- .../api/routes/tests/queries/postgres.spec.ts | 14 +-------- .../src/integrations/tests/utils/mongodb.ts | 29 +++++++++---------- .../src/integrations/tests/utils/postgres.ts | 25 ++++++++-------- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts index cb4ddd9ee1..0c2ba67322 100644 --- a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -6,7 +6,6 @@ import { MongoClient } from "mongodb" jest.unmock("mongodb") describe("/queries", () => { - let request = setup.getRequest() let config = setup.getConfig() let datasource: Datasource @@ -21,18 +20,7 @@ describe("/queries", () => { transformer: "return data", readable: true, } - - const res = await request - .post(`/api/queries`) - .set(config.defaultHeaders()) - .send({ ...defaultQuery, ...query }) - .expect("Content-Type", /json/) - - if (res.status !== 200) { - throw new Error(JSON.stringify(res.body)) - } - - return res.body as Query + return await config.api.query.create({ ...defaultQuery, ...query }) } afterAll(async () => { diff --git a/packages/server/src/api/routes/tests/queries/postgres.spec.ts b/packages/server/src/api/routes/tests/queries/postgres.spec.ts index 4e7f6bffb2..d1302b04f8 100644 --- a/packages/server/src/api/routes/tests/queries/postgres.spec.ts +++ b/packages/server/src/api/routes/tests/queries/postgres.spec.ts @@ -25,7 +25,6 @@ DROP TABLE test_table; ` describe("/queries", () => { - let request = setup.getRequest() let config = setup.getConfig() let datasource: Datasource @@ -40,18 +39,7 @@ describe("/queries", () => { transformer: "return data", readable: true, } - - const res = await request - .post(`/api/queries`) - .set(config.defaultHeaders()) - .send({ ...defaultQuery, ...query }) - .expect("Content-Type", /json/) - - if (res.status !== 200) { - throw new Error(JSON.stringify(res.body)) - } - - return res.body as Query + return await config.api.query.create({ ...defaultQuery, ...query }) } afterAll(async () => { diff --git a/packages/server/src/integrations/tests/utils/mongodb.ts b/packages/server/src/integrations/tests/utils/mongodb.ts index e0a7fa4a0c..ebd76e4baf 100644 --- a/packages/server/src/integrations/tests/utils/mongodb.ts +++ b/packages/server/src/integrations/tests/utils/mongodb.ts @@ -4,25 +4,22 @@ import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" let container: StartedTestContainer | undefined export async function start(): Promise { - if (!container) { - container = await new GenericContainer("mongo:7.0-jammy") - .withExposedPorts(27017) - .withEnvironment({ - MONGO_INITDB_ROOT_USERNAME: "mongo", - MONGO_INITDB_ROOT_PASSWORD: "password", - }) - .withWaitStrategy( - Wait.forSuccessfulCommand( - `mongosh --eval "db.version()"` - ).withStartupTimeout(10000) - ) - .start() - } - return container + return await new GenericContainer("mongo:7.0-jammy") + .withExposedPorts(27017) + .withEnvironment({ + MONGO_INITDB_ROOT_USERNAME: "mongo", + MONGO_INITDB_ROOT_PASSWORD: "password", + }) + .withWaitStrategy( + Wait.forSuccessfulCommand(`mongosh --eval "db.version()"`) + ) + .start() } export async function datasource(): Promise { - const container = await start() + if (!container) { + container = await start() + } const host = container.getHost() const port = container.getMappedPort(27017) return { diff --git a/packages/server/src/integrations/tests/utils/postgres.ts b/packages/server/src/integrations/tests/utils/postgres.ts index fc283ff996..82a62e3916 100644 --- a/packages/server/src/integrations/tests/utils/postgres.ts +++ b/packages/server/src/integrations/tests/utils/postgres.ts @@ -4,22 +4,21 @@ import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" let container: StartedTestContainer | undefined export async function start(): Promise { - if (!container) { - container = await new GenericContainer("postgres:16.1-bullseye") - .withExposedPorts(5432) - .withEnvironment({ POSTGRES_PASSWORD: "password" }) - .withWaitStrategy( - Wait.forSuccessfulCommand( - "pg_isready -h localhost -p 5432" - ).withStartupTimeout(10000) - ) - .start() - } - return container + return await new GenericContainer("postgres:16.1-bullseye") + .withExposedPorts(5432) + .withEnvironment({ POSTGRES_PASSWORD: "password" }) + .withWaitStrategy( + Wait.forSuccessfulCommand( + "pg_isready -h localhost -p 5432" + ).withStartupTimeout(10000) + ) + .start() } export async function datasource(): Promise { - const container = await start() + if (!container) { + container = await start() + } const host = container.getHost() const port = container.getMappedPort(5432) From a773841518799ce80a603ef0f3df7c8a3acdda24 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 14:53:05 +0000 Subject: [PATCH 5/8] Improve error messages relating to failing to connect to datasources. --- .../src/components/integration/QueryViewer.svelte | 8 +++++++- packages/server/src/integrations/postgres.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/components/integration/QueryViewer.svelte b/packages/builder/src/components/integration/QueryViewer.svelte index 59a3289731..42ebcb7693 100644 --- a/packages/builder/src/components/integration/QueryViewer.svelte +++ b/packages/builder/src/components/integration/QueryViewer.svelte @@ -93,7 +93,13 @@ notifications.success("Query executed successfully") } catch (error) { - notifications.error(`Query Error: ${error.message}`) + if (typeof error.message === "string") { + notifications.error(`Query Error: ${error.message}`) + } else if (typeof error.message?.code === "string") { + notifications.error(`Query Error: ${error.message.code}`) + } else { + notifications.error(`Query Error: ${JSON.stringify(error.message)}`) + } if (!suppressErrors) { throw error diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts index 78955c06dc..e1f4cc2fc7 100644 --- a/packages/server/src/integrations/postgres.ts +++ b/packages/server/src/integrations/postgres.ts @@ -202,8 +202,13 @@ class PostgresIntegration extends Sql implements DatasourcePlus { await this.openConnection() response.connected = true } catch (e: any) { - console.log(e) - response.error = e.message as string + if (typeof e.message === "string" && e.message !== "") { + response.error = e.message as string + } else if (typeof e.code === "string" && e.code !== "") { + response.error = e.code + } else { + response.error = "Unknown error" + } } finally { await this.closeConnection() } From 7a012f1f4b2ab1fc46b482339ab20c3832430b59 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 16:49:21 +0000 Subject: [PATCH 6/8] Add tests for create queries. --- .../server/src/api/controllers/query/index.ts | 5 +- .../api/routes/tests/queries/mongodb.spec.ts | 97 +++++++++++++++---- .../api/routes/tests/queries/postgres.spec.ts | 65 ++++++++++--- .../server/src/tests/utilities/api/query.ts | 12 ++- packages/types/src/documents/app/query.ts | 10 ++ 5 files changed, 154 insertions(+), 35 deletions(-) diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts index d38df00443..1be836b169 100644 --- a/packages/server/src/api/controllers/query/index.ts +++ b/packages/server/src/api/controllers/query/index.ts @@ -15,6 +15,9 @@ import { SessionCookie, QuerySchema, FieldType, + type ExecuteQueryRequest, + type ExecuteQueryResponse, + type Row, } from "@budibase/types" import { ValidQueryNameRegex } from "@budibase/shared-core" @@ -223,7 +226,7 @@ export async function preview(ctx: UserCtx) { } async function execute( - ctx: UserCtx, + ctx: UserCtx, opts: any = { rowsOnly: false, isAutomation: false } ) { const db = context.getAppDB() diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts index 0c2ba67322..b736d19750 100644 --- a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -1,7 +1,7 @@ import { Datasource, Query } from "@budibase/types" import * as setup from "../utilities" import { databaseTestProviders } from "../../../../integrations/tests/utils" -import { MongoClient } from "mongodb" +import { MongoClient, type Collection } from "mongodb" jest.unmock("mongodb") @@ -23,6 +23,31 @@ describe("/queries", () => { return await config.api.query.create({ ...defaultQuery, ...query }) } + async function withClient( + callback: (client: MongoClient) => Promise + ): Promise { + const ds = await databaseTestProviders.mongodb.datasource() + const client = new MongoClient(ds.config!.connectionString) + await client.connect() + try { + await callback(client) + } finally { + await client.close() + } + } + + async function withCollection( + collection: string, + callback: (collection: Collection) => Promise + ): Promise { + await withClient(async client => { + const db = client.db( + (await databaseTestProviders.mongodb.datasource()).config!.db + ) + await callback(db.collection(collection)) + }) + } + afterAll(async () => { await databaseTestProviders.mongodb.stop() setup.afterAll() @@ -36,29 +61,21 @@ describe("/queries", () => { }) beforeEach(async () => { - const ds = await databaseTestProviders.mongodb.datasource() - const client = new MongoClient(ds.config!.connectionString) - await client.connect() - - const db = client.db(ds.config!.db) - const collection = db.collection("test_table") - await collection.insertMany([ - { name: "one" }, - { name: "two" }, - { name: "three" }, - { name: "four" }, - { name: "five" }, - ]) - await client.close() + await withCollection("test_table", async collection => { + await collection.insertMany([ + { name: "one" }, + { name: "two" }, + { name: "three" }, + { name: "four" }, + { name: "five" }, + ]) + }) }) afterEach(async () => { - const ds = await databaseTestProviders.mongodb.datasource() - const client = new MongoClient(ds.config!.connectionString) - await client.connect() - const db = client.db(ds.config!.db) - await db.collection("test_table").drop() - await client.close() + await withCollection("test_table", async collection => { + await collection.drop() + }) }) it("should execute a query", async () => { @@ -93,4 +110,42 @@ describe("/queries", () => { expect(result.data).toEqual([{ value: 6 }]) }) + + it("should execute a create query with parameters", async () => { + const query = await createQuery({ + fields: { + json: '{"foo": "{{ foo }}"}', + extra: { + actionType: "insertOne", + collection: "test_table", + }, + }, + queryVerb: "create", + parameters: [ + { + name: "foo", + default: "default", + }, + ], + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { foo: "bar" }, + }) + + expect(result.data).toEqual([ + { + acknowledged: true, + insertedId: expect.anything(), + }, + ]) + + await withCollection("test_table", async collection => { + const doc = await collection.findOne({ foo: { $eq: "bar" } }) + expect(doc).toEqual({ + _id: expect.anything(), + foo: "bar", + }) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/queries/postgres.spec.ts b/packages/server/src/api/routes/tests/queries/postgres.spec.ts index d1302b04f8..487644e787 100644 --- a/packages/server/src/api/routes/tests/queries/postgres.spec.ts +++ b/packages/server/src/api/routes/tests/queries/postgres.spec.ts @@ -42,6 +42,19 @@ describe("/queries", () => { return await config.api.query.create({ ...defaultQuery, ...query }) } + async function withClient( + callback: (client: Client) => Promise + ): Promise { + const ds = await databaseTestProviders.postgres.datasource() + const client = new Client(ds.config!) + await client.connect() + try { + await callback(client) + } finally { + await client.end() + } + } + afterAll(async () => { await databaseTestProviders.postgres.stop() setup.afterAll() @@ -55,20 +68,16 @@ describe("/queries", () => { }) beforeEach(async () => { - const ds = await databaseTestProviders.postgres.datasource() - const client = new Client(ds.config!) - await client.connect() - await client.query(createTableSQL) - await client.query(insertSQL) - await client.end() + await withClient(async client => { + await client.query(createTableSQL) + await client.query(insertSQL) + }) }) afterEach(async () => { - const ds = await databaseTestProviders.postgres.datasource() - const client = new Client(ds.config!) - await client.connect() - await client.query(dropTableSQL) - await client.end() + await withClient(async client => { + await client.query(dropTableSQL) + }) }) it("should execute a query", async () => { @@ -124,4 +133,38 @@ describe("/queries", () => { }, ]) }) + + it("should be able to insert with bindings", async () => { + const query = await createQuery({ + fields: { + sql: "INSERT INTO test_table (name) VALUES ({{ foo }})", + }, + parameters: [ + { + name: "foo", + default: "bar", + }, + ], + queryVerb: "create", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + foo: "baz", + }, + }) + + expect(result.data).toEqual([ + { + created: true, + }, + ]) + + await withClient(async client => { + const { rows } = await client.query( + "SELECT * FROM test_table WHERE name = 'baz'" + ) + expect(rows).toHaveLength(1) + }) + }) }) diff --git a/packages/server/src/tests/utilities/api/query.ts b/packages/server/src/tests/utilities/api/query.ts index 98ea91c60f..350fe03c74 100644 --- a/packages/server/src/tests/utilities/api/query.ts +++ b/packages/server/src/tests/utilities/api/query.ts @@ -1,5 +1,9 @@ import TestConfiguration from "../TestConfiguration" -import { Query } from "@budibase/types" +import { + Query, + type ExecuteQueryRequest, + type ExecuteQueryResponse, +} from "@budibase/types" import { TestAPI } from "./base" export class QueryAPI extends TestAPI { @@ -21,10 +25,14 @@ export class QueryAPI extends TestAPI { return res.body as Query } - execute = async (queryId: string): Promise<{ data: any }> => { + execute = async ( + queryId: string, + body?: ExecuteQueryRequest + ): Promise => { const res = await this.request .post(`/api/v2/queries/${queryId}`) .set(this.config.defaultHeaders()) + .send(body) .expect("Content-Type", /json/) if (res.status !== 200) { diff --git a/packages/types/src/documents/app/query.ts b/packages/types/src/documents/app/query.ts index 473449bffb..790c297813 100644 --- a/packages/types/src/documents/app/query.ts +++ b/packages/types/src/documents/app/query.ts @@ -1,4 +1,5 @@ import { Document } from "../document" +import type { Row } from "./row" export interface QuerySchema { name?: string @@ -54,3 +55,12 @@ export interface PreviewQueryRequest extends Omit { urlName?: boolean } } + +export interface ExecuteQueryRequest { + parameters?: { [key: string]: string } + pagination?: any +} + +export interface ExecuteQueryResponse { + data: Row[] +} From 4d1b13f7546329bd87f50c8e640e90c17624886d Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 5 Feb 2024 17:45:38 +0000 Subject: [PATCH 7/8] Flesh out MongoDB query tests a bit more. --- .../api/routes/tests/queries/mongodb.spec.ts | 216 ++++++++++++++++-- 1 file changed, 203 insertions(+), 13 deletions(-) diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts index b736d19750..8184f40b88 100644 --- a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -5,6 +5,8 @@ import { MongoClient, type Collection } from "mongodb" jest.unmock("mongodb") +const collection = "test_collection" + describe("/queries", () => { let config = setup.getConfig() let datasource: Datasource @@ -20,7 +22,15 @@ describe("/queries", () => { transformer: "return data", readable: true, } - return await config.api.query.create({ ...defaultQuery, ...query }) + const combinedQuery = { ...defaultQuery, ...query } + if ( + combinedQuery.fields && + combinedQuery.fields.extra && + !combinedQuery.fields.extra.collection + ) { + combinedQuery.fields.extra.collection = collection + } + return await config.api.query.create(combinedQuery) } async function withClient( @@ -37,7 +47,6 @@ describe("/queries", () => { } async function withCollection( - collection: string, callback: (collection: Collection) => Promise ): Promise { await withClient(async client => { @@ -61,7 +70,7 @@ describe("/queries", () => { }) beforeEach(async () => { - await withCollection("test_table", async collection => { + await withCollection(async collection => { await collection.insertMany([ { name: "one" }, { name: "two" }, @@ -73,18 +82,17 @@ describe("/queries", () => { }) afterEach(async () => { - await withCollection("test_table", async collection => { + await withCollection(async collection => { await collection.drop() }) }) - it("should execute a query", async () => { + it("should execute a count query", async () => { const query = await createQuery({ fields: { - json: "{}", + json: {}, extra: { actionType: "count", - collection: "test_table", }, }, }) @@ -94,13 +102,12 @@ describe("/queries", () => { expect(result.data).toEqual([{ value: 5 }]) }) - it("should execute a query with a transformer", async () => { + it("should execute a count query with a transformer", async () => { const query = await createQuery({ fields: { - json: "{}", + json: {}, extra: { actionType: "count", - collection: "test_table", }, }, transformer: "return data + 1", @@ -111,13 +118,48 @@ describe("/queries", () => { expect(result.data).toEqual([{ value: 6 }]) }) + it("should execute a find query", async () => { + const query = await createQuery({ + fields: { + json: {}, + extra: { + actionType: "find", + }, + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { _id: expect.anything(), name: "one" }, + { _id: expect.anything(), name: "two" }, + { _id: expect.anything(), name: "three" }, + { _id: expect.anything(), name: "four" }, + { _id: expect.anything(), name: "five" }, + ]) + }) + + it("should execute a findOne query", async () => { + const query = await createQuery({ + fields: { + json: {}, + extra: { + actionType: "findOne", + }, + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([{ _id: expect.anything(), name: "one" }]) + }) + it("should execute a create query with parameters", async () => { const query = await createQuery({ fields: { - json: '{"foo": "{{ foo }}"}', + json: { foo: "{{ foo }}" }, extra: { actionType: "insertOne", - collection: "test_table", }, }, queryVerb: "create", @@ -140,7 +182,7 @@ describe("/queries", () => { }, ]) - await withCollection("test_table", async collection => { + await withCollection(async collection => { const doc = await collection.findOne({ foo: { $eq: "bar" } }) expect(doc).toEqual({ _id: expect.anything(), @@ -148,4 +190,152 @@ describe("/queries", () => { }) }) }) + + it("should execute a delete query with parameters", async () => { + const query = await createQuery({ + fields: { + json: { name: { $eq: "{{ name }}" } }, + extra: { + actionType: "deleteOne", + }, + }, + queryVerb: "delete", + parameters: [ + { + name: "name", + default: "", + }, + ], + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { name: "one" }, + }) + + expect(result.data).toEqual([ + { + acknowledged: true, + deletedCount: 1, + }, + ]) + + await withCollection(async collection => { + const doc = await collection.findOne({ name: { $eq: "one" } }) + expect(doc).toBeNull() + }) + }) + + it("should execute an update query with parameters", async () => { + const query = await createQuery({ + fields: { + json: { + filter: { name: { $eq: "{{ name }}" } }, + update: { $set: { name: "{{ newName }}" } }, + }, + extra: { + actionType: "updateOne", + }, + }, + queryVerb: "update", + parameters: [ + { + name: "name", + default: "", + }, + { + name: "newName", + default: "", + }, + ], + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { name: "one", newName: "newOne" }, + }) + + expect(result.data).toEqual([ + { + acknowledged: true, + matchedCount: 1, + modifiedCount: 1, + upsertedCount: 0, + upsertedId: null, + }, + ]) + + await withCollection(async collection => { + const doc = await collection.findOne({ name: { $eq: "newOne" } }) + expect(doc).toEqual({ + _id: expect.anything(), + name: "newOne", + }) + + const oldDoc = await collection.findOne({ name: { $eq: "one" } }) + expect(oldDoc).toBeNull() + }) + }) + + it("should be able to delete all records", async () => { + const query = await createQuery({ + fields: { + json: {}, + extra: { + actionType: "deleteMany", + }, + }, + queryVerb: "delete", + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + acknowledged: true, + deletedCount: 5, + }, + ]) + + await withCollection(async collection => { + const docs = await collection.find().toArray() + expect(docs).toHaveLength(0) + }) + }) + + it("should be able to update all documents", async () => { + const query = await createQuery({ + fields: { + json: { + filter: {}, + update: { $set: { name: "newName" } }, + }, + extra: { + actionType: "updateMany", + }, + }, + queryVerb: "update", + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + acknowledged: true, + matchedCount: 5, + modifiedCount: 5, + upsertedCount: 0, + upsertedId: null, + }, + ]) + + await withCollection(async collection => { + const docs = await collection.find().toArray() + expect(docs).toHaveLength(5) + for (const doc of docs) { + expect(doc).toEqual({ + _id: expect.anything(), + name: "newName", + }) + } + }) + }) }) From 8bb25c471504134c0004e988a80acc59ad720c44 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 6 Feb 2024 10:47:47 +0000 Subject: [PATCH 8/8] More MongoDB query tests. --- .../api/routes/tests/queries/mongodb.spec.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts index 8184f40b88..a9c35cba7d 100644 --- a/packages/server/src/api/routes/tests/queries/mongodb.spec.ts +++ b/packages/server/src/api/routes/tests/queries/mongodb.spec.ts @@ -154,6 +154,55 @@ describe("/queries", () => { expect(result.data).toEqual([{ _id: expect.anything(), name: "one" }]) }) + it("should execute a findOneAndUpdate query", async () => { + const query = await createQuery({ + fields: { + json: { + filter: { name: { $eq: "one" } }, + update: { $set: { name: "newName" } }, + }, + extra: { + actionType: "findOneAndUpdate", + }, + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + lastErrorObject: { n: 1, updatedExisting: true }, + ok: 1, + value: { _id: expect.anything(), name: "one" }, + }, + ]) + + await withCollection(async collection => { + expect(await collection.countDocuments()).toBe(5) + + const doc = await collection.findOne({ name: { $eq: "newName" } }) + expect(doc).toEqual({ + _id: expect.anything(), + name: "newName", + }) + }) + }) + + it("should execute a distinct query", async () => { + const query = await createQuery({ + fields: { + json: "name", + extra: { + actionType: "distinct", + }, + }, + }) + + const result = await config.api.query.execute(query._id!) + const values = result.data.map(o => o.value).sort() + expect(values).toEqual(["five", "four", "one", "three", "two"]) + }) + it("should execute a create query with parameters", async () => { const query = await createQuery({ fields: {