import { SqlClient } from "../utils" import Sql from "../base/sql" import { Operation, QueryJson, TableSourceType, Table, FieldType, } from "@budibase/types" const TABLE_NAME = "test" const TABLE: Table = { type: "table", sourceType: TableSourceType.EXTERNAL, sourceId: "SOURCE_ID", schema: {}, name: TABLE_NAME, primary: ["id"], } function endpoint(table: any, operation: any) { return { datasourceId: "Postgres", operation: operation, entityId: table || TABLE_NAME, } } function generateReadJson({ table, fields, filters, sort, paginate, }: any = {}): QueryJson { const tableObj = { ...TABLE } if (table) { tableObj.name = table } return { endpoint: endpoint(table || TABLE_NAME, "READ"), resource: { fields: fields || [], }, filters: filters || {}, sort: sort || {}, paginate: paginate || {}, meta: { table: tableObj, }, } } function generateCreateJson(table = TABLE_NAME, body = {}): QueryJson { return { endpoint: endpoint(table, "CREATE"), meta: { table: TABLE, }, body, } } function generateUpdateJson({ table = TABLE_NAME, body = {}, filters = {}, meta = {}, }: { table: string body?: any filters?: any meta?: any }): QueryJson { if (!meta.table) { meta.table = table } return { endpoint: endpoint(table, "UPDATE"), filters, body, meta, } } function generateDeleteJson(table = TABLE_NAME, filters = {}): QueryJson { return { endpoint: endpoint(table, "DELETE"), meta: { table: TABLE, }, filters, } } function generateRelationshipJson(config: { schema?: string } = {}): QueryJson { return { endpoint: { datasourceId: "Postgres", entityId: "brands", operation: Operation.READ, schema: config.schema, }, resource: { fields: [ "brands.brand_id", "brands.brand_name", "products.product_id", "products.product_name", "products.brand_id", ], }, filters: {}, sort: {}, relationships: [ { from: "brand_id", to: "brand_id", tableName: "products", column: "products", }, ], extra: { idFilter: {} }, meta: { table: TABLE, }, } } function generateManyRelationshipJson(config: { schema?: string } = {}) { return { endpoint: { datasourceId: "Postgres", entityId: "stores", operation: "READ", schema: config.schema, }, resource: { fields: [ "stores.store_id", "stores.store_name", "products.product_id", "products.product_name", ], }, filters: {}, sort: {}, paginate: {}, relationships: [ { from: "store_id", to: "product_id", tableName: "products", column: "products", through: "stocks", fromPrimary: "store_id", toPrimary: "product_id", }, ], extra: { idFilter: {} }, } } describe("SQL query builder", () => { const limit = 500 const client = SqlClient.POSTGRES let sql: any beforeEach(() => { sql = new Sql(client, limit) }) it("should test a basic read", () => { const query = sql._query(generateReadJson()) expect(query).toEqual({ bindings: [limit], sql: `select * from (select * from "${TABLE_NAME}" limit $1) as "${TABLE_NAME}"`, }) }) it("should test a read with specific columns", () => { const nameProp = `${TABLE_NAME}.name`, ageProp = `${TABLE_NAME}.age` const query = sql._query( generateReadJson({ fields: [nameProp, ageProp], }) ) expect(query).toEqual({ bindings: [limit], sql: `select "${TABLE_NAME}"."name" as "${nameProp}", "${TABLE_NAME}"."age" as "${ageProp}" from (select * from "${TABLE_NAME}" limit $1) as "${TABLE_NAME}"`, }) }) it("should test a where string starts with read", () => { const query = sql._query( generateReadJson({ filters: { string: { name: "John", }, }, }) ) expect(query).toEqual({ bindings: ["John%", limit], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."name" ilike $1 limit $2) as "${TABLE_NAME}"`, }) }) it("should test a where range read", () => { const query = sql._query( generateReadJson({ filters: { range: { age: { low: 2, high: 10, }, }, }, }) ) expect(query).toEqual({ bindings: [2, 10, limit], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age" between $1 and $2 limit $3) as "${TABLE_NAME}"`, }) }) it("should test for multiple IDs with OR", () => { const query = sql._query( generateReadJson({ filters: { equal: { age: 10, name: "John", }, allOr: true, }, }) ) expect(query).toEqual({ bindings: [10, "John", limit], sql: `select * from (select * from "${TABLE_NAME}" where ("${TABLE_NAME}"."age" = $1) or ("${TABLE_NAME}"."name" = $2) limit $3) as "${TABLE_NAME}"`, }) }) it("should allow filtering on a related field", () => { const query = sql._query( generateReadJson({ filters: { equal: { age: 10, "task.name": "task 1", }, }, }) ) // order of bindings changes because relationship filters occur outside inner query expect(query).toEqual({ bindings: [10, limit, "task 1"], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age" = $1 limit $2) as "${TABLE_NAME}" where "task"."name" = $3`, }) }) it("should test an create statement", () => { const query = sql._query( generateCreateJson(TABLE_NAME, { name: "Michael", age: 45, }) ) expect(query).toEqual({ bindings: [45, "Michael"], sql: `insert into "${TABLE_NAME}" ("age", "name") values ($1, $2) returning *`, }) }) it("should test an update statement", () => { const query = sql._query( generateUpdateJson({ table: TABLE_NAME, body: { name: "John", }, filters: { equal: { id: 1001, }, }, }) ) expect(query).toEqual({ bindings: ["John", 1001], sql: `update "${TABLE_NAME}" set "name" = $1 where "${TABLE_NAME}"."id" = $2 returning *`, }) }) it("should test a delete statement", () => { const query = sql._query( generateDeleteJson(TABLE_NAME, { equal: { id: 1001, }, }) ) expect(query).toEqual({ bindings: [1001], sql: `delete from "${TABLE_NAME}" where "${TABLE_NAME}"."id" = $1 returning *`, }) }) it("should work with MS-SQL", () => { const query = new Sql(SqlClient.MS_SQL, 10)._query(generateReadJson()) expect(query).toEqual({ bindings: [10], sql: `select * from (select top (@p0) * from [${TABLE_NAME}]) as [${TABLE_NAME}]`, }) }) it("should work with MySQL", () => { const query = new Sql(SqlClient.MY_SQL, 10)._query(generateReadJson()) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from \`${TABLE_NAME}\` limit ?) as \`${TABLE_NAME}\``, }) }) it("should use greater than when only low range specified", () => { const date = new Date() const query = sql._query( generateReadJson({ filters: { range: { property: { low: date, }, }, }, }) ) expect(query).toEqual({ bindings: [date, limit], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."property" > $1 limit $2) as "${TABLE_NAME}"`, }) }) it("should use less than when only high range specified", () => { const date = new Date() const query = sql._query( generateReadJson({ filters: { range: { property: { high: date, }, }, }, }) ) expect(query).toEqual({ bindings: [date, limit], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."property" < $1 limit $2) as "${TABLE_NAME}"`, }) }) it("should use AND like expression for MS-SQL when filter is contains", () => { const query = new Sql(SqlClient.MS_SQL, 10)._query( generateReadJson({ filters: { contains: { age: [20, 25], name: ["John", "Mary"], }, }, }) ) expect(query).toEqual({ bindings: [10, "%20%", "%25%", `%"john"%`, `%"mary"%`], sql: `select * from (select top (@p0) * from [${TABLE_NAME}] where (LOWER([${TABLE_NAME}].[age]) LIKE @p1 AND LOWER([${TABLE_NAME}].[age]) LIKE @p2) and (LOWER([${TABLE_NAME}].[name]) LIKE @p3 AND LOWER([${TABLE_NAME}].[name]) LIKE @p4)) as [${TABLE_NAME}]`, }) }) it("should use JSON_CONTAINS expression for MySQL when filter is contains", () => { const query = new Sql(SqlClient.MY_SQL, 10)._query( generateReadJson({ filters: { contains: { age: [20], name: ["John"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from \`${TABLE_NAME}\` where JSON_CONTAINS(${TABLE_NAME}.age, '[20]') and JSON_CONTAINS(${TABLE_NAME}.name, '["John"]') limit ?) as \`${TABLE_NAME}\``, }) }) it("should use jsonb operator expression for PostgreSQL when filter is contains", () => { const query = new Sql(SqlClient.POSTGRES, 10)._query( generateReadJson({ filters: { contains: { age: [20], name: ["John"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age"::jsonb @> '[20]' and "${TABLE_NAME}"."name"::jsonb @> '["John"]' limit $1) as "${TABLE_NAME}"`, }) }) it("should use NOT like expression for MS-SQL when filter is notContains", () => { const query = new Sql(SqlClient.MS_SQL, 10)._query( generateReadJson({ filters: { notContains: { age: [20], name: ["John"], }, }, }) ) expect(query).toEqual({ bindings: [10, "%20%", `%"john"%`], sql: `select * from (select top (@p0) * from [${TABLE_NAME}] where NOT (LOWER([${TABLE_NAME}].[age]) LIKE @p1) and NOT (LOWER([${TABLE_NAME}].[name]) LIKE @p2)) as [${TABLE_NAME}]`, }) }) it("should use NOT JSON_CONTAINS expression for MySQL when filter is notContains", () => { const query = new Sql(SqlClient.MY_SQL, 10)._query( generateReadJson({ filters: { notContains: { age: [20], name: ["John"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from \`${TABLE_NAME}\` where NOT JSON_CONTAINS(${TABLE_NAME}.age, '[20]') and NOT JSON_CONTAINS(${TABLE_NAME}.name, '["John"]') limit ?) as \`${TABLE_NAME}\``, }) }) it("should use jsonb operator NOT expression for PostgreSQL when filter is notContains", () => { const query = new Sql(SqlClient.POSTGRES, 10)._query( generateReadJson({ filters: { notContains: { age: [20], name: ["John"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from "${TABLE_NAME}" where NOT "${TABLE_NAME}"."age"::jsonb @> '[20]' and NOT "${TABLE_NAME}"."name"::jsonb @> '["John"]' limit $1) as "${TABLE_NAME}"`, }) }) it("should use OR like expression for MS-SQL when filter is containsAny", () => { const query = new Sql(SqlClient.MS_SQL, 10)._query( generateReadJson({ filters: { containsAny: { age: [20, 25], name: ["John", "Mary"], }, }, }) ) expect(query).toEqual({ bindings: [10, "%20%", "%25%", `%"john"%`, `%"mary"%`], sql: `select * from (select top (@p0) * from [${TABLE_NAME}] where (LOWER([${TABLE_NAME}].[age]) LIKE @p1 OR LOWER([${TABLE_NAME}].[age]) LIKE @p2) and (LOWER([${TABLE_NAME}].[name]) LIKE @p3 OR LOWER([${TABLE_NAME}].[name]) LIKE @p4)) as [${TABLE_NAME}]`, }) }) it("should use JSON_OVERLAPS expression for MySQL when filter is containsAny", () => { const query = new Sql(SqlClient.MY_SQL, 10)._query( generateReadJson({ filters: { containsAny: { age: [20, 25], name: ["John", "Mary"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from \`${TABLE_NAME}\` where JSON_OVERLAPS(${TABLE_NAME}.age, '[20,25]') and JSON_OVERLAPS(${TABLE_NAME}.name, '["John","Mary"]') limit ?) as \`${TABLE_NAME}\``, }) }) it("should use ?| operator expression for PostgreSQL when filter is containsAny", () => { const query = new Sql(SqlClient.POSTGRES, 10)._query( generateReadJson({ filters: { containsAny: { age: [20, 25], name: ["John", "Mary"], }, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age"::jsonb ?| array [20,25] and "${TABLE_NAME}"."name"::jsonb ?| array ['John','Mary'] limit $1) as "${TABLE_NAME}"`, }) }) it("should add the schema to the LEFT JOIN", () => { const query = sql._query(generateRelationshipJson({ schema: "production" })) expect(query).toEqual({ bindings: [500, 5000], sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "production"."brands" limit $1) as "brands" left join "production"."products" as "products" on "brands"."brand_id" = "products"."brand_id" limit $2`, }) }) it("should handle if the schema is not present when doing a LEFT JOIN", () => { const query = sql._query(generateRelationshipJson()) expect(query).toEqual({ bindings: [500, 5000], sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "brands" limit $1) as "brands" left join "products" as "products" on "brands"."brand_id" = "products"."brand_id" limit $2`, }) }) it("should add the schema to both the toTable and throughTable in many-to-many join", () => { const query = sql._query( generateManyRelationshipJson({ schema: "production" }) ) expect(query).toEqual({ bindings: [500, 5000], sql: `select "stores"."store_id" as "stores.store_id", "stores"."store_name" as "stores.store_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name" from (select * from "production"."stores" limit $1) as "stores" left join "production"."stocks" as "stocks" on "stores"."store_id" = "stocks"."store_id" left join "production"."products" as "products" on "products"."product_id" = "stocks"."product_id" limit $2`, }) }) it("should handle table names with dashes when performing a LIKE in MySQL", () => { const tableName = "Table-Name-With-Dashes" const query = new Sql(SqlClient.MY_SQL, limit)._query( generateReadJson({ table: tableName, filters: { string: { name: "John", }, }, }) ) expect(query).toEqual({ bindings: ["john%", limit], sql: `select * from (select * from \`${tableName}\` where LOWER(\`${tableName}\`.\`name\`) LIKE ? limit ?) as \`${tableName}\``, }) }) it("should handle table names with dashes when performing a LIKE in SQL Server", () => { const tableName = "Table-Name-With-Dashes" const query = new Sql(SqlClient.MS_SQL, limit)._query( generateReadJson({ table: tableName, filters: { string: { name: "John", }, }, }) ) expect(query).toEqual({ bindings: [limit, "john%"], sql: `select * from (select top (@p0) * from [${tableName}] where LOWER([${tableName}].[name]) LIKE @p1) as [${tableName}]`, }) }) it("should ignore high range value if it is an empty object", () => { const query = sql._query( generateReadJson({ filters: { range: { dob: { low: "2000-01-01 00:00:00", high: {}, }, }, }, }) ) expect(query).toEqual({ bindings: ["2000-01-01 00:00:00", 500], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" > $1 limit $2) as "${TABLE_NAME}"`, }) }) it("should ignore low range value if it is an empty object", () => { const query = sql._query( generateReadJson({ filters: { range: { dob: { low: {}, high: "2010-01-01 00:00:00", }, }, }, }) ) expect(query).toEqual({ bindings: ["2010-01-01 00:00:00", 500], sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" < $1 limit $2) as "${TABLE_NAME}"`, }) }) it("should lowercase the values for Oracle LIKE statements", () => { let query = new Sql(SqlClient.ORACLE, limit)._query( generateReadJson({ filters: { string: { name: "John", }, }, }) ) expect(query).toEqual({ bindings: ["john%", limit], sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`, }) query = new Sql(SqlClient.ORACLE, limit)._query( generateReadJson({ filters: { contains: { age: [20, 25], name: ["John", "Mary"], }, }, }) ) expect(query).toEqual({ bindings: ["%20%", "%25%", `%"john"%`, `%"mary"%`, limit], sql: `select * from (select * from (select * from "test" where (LOWER("test"."age") LIKE :1 AND LOWER("test"."age") LIKE :2) and (LOWER("test"."name") LIKE :3 AND LOWER("test"."name") LIKE :4)) where rownum <= :5) "test"`, }) query = new Sql(SqlClient.ORACLE, limit)._query( generateReadJson({ filters: { fuzzy: { name: "Jo", }, }, }) ) expect(query).toEqual({ bindings: [`%jo%`, limit], sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`, }) }) it("should sort SQL Server tables by the primary key if no sort data is provided", () => { let query = new Sql(SqlClient.MS_SQL, limit)._query( generateReadJson({ sort: {}, paginate: { limit: 10, }, }) ) expect(query).toEqual({ bindings: [10], sql: `select * from (select top (@p0) * from [test] order by [test].[id] asc) as [test]`, }) }) it("should not parse JSON string as Date", () => { let query = new Sql(SqlClient.POSTGRES, limit)._query( generateCreateJson(TABLE_NAME, { name: '{ "created_at":"2023-09-09T03:21:06.024Z" }', }) ) expect(query).toEqual({ bindings: ['{ "created_at":"2023-09-09T03:21:06.024Z" }'], sql: `insert into "test" ("name") values ($1) returning *`, }) }) it("should parse and trim valid string as Date", () => { const dateObj = new Date("2023-09-09T03:21:06.024Z") let query = new Sql(SqlClient.POSTGRES, limit)._query( generateCreateJson(TABLE_NAME, { name: " 2023-09-09T03:21:06.024Z ", }) ) expect(query).toEqual({ bindings: [dateObj], sql: `insert into "test" ("name") values ($1) returning *`, }) }) it("should be able to rename column for MySQL", () => { const table: Table = { type: "table", sourceType: TableSourceType.EXTERNAL, name: TABLE_NAME, schema: { first_name: { type: FieldType.STRING, name: "first_name", externalType: "varchar(45)", }, }, sourceId: "SOURCE_ID", } const oldTable: Table = { ...table, schema: { name: { type: FieldType.STRING, name: "name", externalType: "varchar(45)", }, }, } const query = new Sql(SqlClient.MY_SQL, limit)._query({ table, endpoint: { datasourceId: "MySQL", operation: Operation.UPDATE_TABLE, entityId: TABLE_NAME, }, meta: { table: oldTable, tables: { [oldTable.name]: oldTable }, renamed: { old: "name", updated: "first_name", }, }, }) expect(query).toEqual({ bindings: [], sql: `alter table \`${TABLE_NAME}\` rename column \`name\` to \`first_name\`;`, }) }) it("should be able to delete a column", () => { const table: Table = { type: "table", sourceType: TableSourceType.EXTERNAL, name: TABLE_NAME, schema: { first_name: { type: FieldType.STRING, name: "first_name", externalType: "varchar(45)", }, }, sourceId: "SOURCE_ID", } const oldTable: Table = { ...table, schema: { first_name: { type: FieldType.STRING, name: "first_name", externalType: "varchar(45)", }, last_name: { type: FieldType.STRING, name: "last_name", externalType: "varchar(45)", }, }, } const query = sql._query({ table, endpoint: { datasourceId: "Postgres", operation: Operation.UPDATE_TABLE, entityId: TABLE_NAME, }, meta: { table: oldTable, tables: [oldTable], }, }) expect(query).toEqual([ { bindings: [], sql: `alter table "${TABLE_NAME}" drop column "last_name"`, }, ]) }) })