diff --git a/hosting/docker-compose.build.yaml b/hosting/docker-compose.build.yaml index dbc3613599..253dda0232 100644 --- a/hosting/docker-compose.build.yaml +++ b/hosting/docker-compose.build.yaml @@ -29,6 +29,7 @@ services: BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL} BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD} PLUGINS_DIR: ${PLUGINS_DIR} + SQS_SEARCH_ENABLE: 1 depends_on: - worker-service - redis-service @@ -56,6 +57,7 @@ services: INTERNAL_API_KEY: ${INTERNAL_API_KEY} REDIS_URL: redis-service:6379 REDIS_PASSWORD: ${REDIS_PASSWORD} + SQS_SEARCH_ENABLE: 1 depends_on: - redis-service - minio-service diff --git a/hosting/docker-compose.dev.yaml b/hosting/docker-compose.dev.yaml index 9dba5d427c..77f6bd053b 100644 --- a/hosting/docker-compose.dev.yaml +++ b/hosting/docker-compose.dev.yaml @@ -42,12 +42,13 @@ services: couchdb-service: container_name: budi-couchdb3-dev restart: on-failure - image: budibase/couchdb + image: budibase/couchdb:v3.2.1-sqs environment: - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_USER=${COUCH_DB_USER} ports: - "${COUCH_DB_PORT}:5984" + - "${COUCH_DB_SQS_PORT}:4984" volumes: - couchdb_data:/data diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index ff35ccee22..f61059cc97 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -54,7 +54,8 @@ "sanitize-s3-objectkey": "0.0.1", "semver": "^7.5.4", "tar-fs": "2.1.1", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "knex": "2.4.2" }, "devDependencies": { "@shopify/jest-koa-mocks": "5.1.1", diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index c11c227b66..2fd713119b 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -65,5 +65,11 @@ export const StaticDatabases = { export const APP_PREFIX = prefixed(DocumentType.APP) export const APP_DEV = prefixed(DocumentType.APP_DEV) export const APP_DEV_PREFIX = APP_DEV +export const SQS_DATASOURCE_INTERNAL = "internal" export const BUDIBASE_DATASOURCE_TYPE = "budibase" export const SQLITE_DESIGN_DOC_ID = "_design/sqlite" +export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs" +export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" +export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" +export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" +export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default" diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 20ff16739f..1e7da2f9a2 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -159,6 +159,9 @@ const environment = { process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose", HTTP_LOGGING: httpLogging(), ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR, + // Couch/search + SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE, + SQL_MAX_ROWS: process.env.SQL_MAX_ROWS, // smtp SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED, SMTP_USER: process.env.SMTP_USER, diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 5ce35ee760..30c5fbdd7a 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -34,6 +34,7 @@ export * as docUpdates from "./docUpdates" export * from "./utils/Duration" export * as docIds from "./docIds" export * as security from "./security" +export * as sql from "./sql" // Add context to tenancy for backwards compatibility // only do this for external usages to prevent internal // circular dependencies diff --git a/packages/backend-core/src/sql/designDoc.ts b/packages/backend-core/src/sql/designDoc.ts new file mode 100644 index 0000000000..dc334496a0 --- /dev/null +++ b/packages/backend-core/src/sql/designDoc.ts @@ -0,0 +1,17 @@ +import { PreSaveSQLiteDefinition } from "@budibase/types" +import { SQLITE_DESIGN_DOC_ID } from "../constants" + +// the table id property defines which property in the document +// to use when splitting the documents into different sqlite tables +export function base(tableIdProp: string): PreSaveSQLiteDefinition { + return { + _id: SQLITE_DESIGN_DOC_ID, + language: "sqlite", + sql: { + tables: {}, + options: { + table_name: tableIdProp, + }, + }, + } +} diff --git a/packages/backend-core/src/sql/index.ts b/packages/backend-core/src/sql/index.ts new file mode 100644 index 0000000000..16b718d2e6 --- /dev/null +++ b/packages/backend-core/src/sql/index.ts @@ -0,0 +1,5 @@ +export * as utils from "./utils" + +export { default as Sql } from "./sql" +export { default as SqlTable } from "./sqlTable" +export * as designDoc from "./designDoc" diff --git a/packages/server/src/integrations/base/sql.ts b/packages/backend-core/src/sql/sql.ts similarity index 96% rename from packages/server/src/integrations/base/sql.ts rename to packages/backend-core/src/sql/sql.ts index 43e5961947..2d01c6a7ee 100644 --- a/packages/server/src/integrations/base/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -1,13 +1,7 @@ import { Knex, knex } from "knex" -import { db as dbCore } from "@budibase/backend-core" -import { QueryOptions } from "../../definitions/datasource" -import { - isIsoDateString, - SqlClient, - isValidFilter, - getNativeSql, - SqlStatements, -} from "../utils" +import * as dbCore from "../db" +import { isIsoDateString, isValidFilter, getNativeSql } from "./utils" +import { SqlStatements } from "./sqlStatements" import SqlTableQueryBuilder from "./sqlTable" import { BBReferenceFieldMetadata, @@ -24,8 +18,11 @@ import { Table, TableSourceType, INTERNAL_TABLE_SOURCE_ID, + SqlClient, + QueryOptions, + JsonTypes, } from "@budibase/types" -import environment from "../../environment" +import environment from "../environment" import { helpers } from "@budibase/shared-core" type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any @@ -45,6 +42,7 @@ function likeKey(client: string, key: string): string { case SqlClient.MY_SQL: start = end = "`" break + case SqlClient.SQL_LITE: case SqlClient.ORACLE: case SqlClient.POSTGRES: start = end = '"' @@ -53,9 +51,6 @@ function likeKey(client: string, key: string): string { start = "[" end = "]" break - case SqlClient.SQL_LITE: - start = end = "'" - break default: throw new Error("Unknown client generating like key") } @@ -207,17 +202,20 @@ class InternalBuilder { const updatedKey = dbCore.removeKeyNumbering(key) const isRelationshipField = updatedKey.includes(".") if (!opts.relationship && !isRelationshipField) { - fn(`${getTableAlias(tableName)}.${updatedKey}`, value) + const alias = getTableAlias(tableName) + fn(alias ? `${alias}.${updatedKey}` : updatedKey, value) } if (opts.relationship && isRelationshipField) { const [filterTableName, property] = updatedKey.split(".") - fn(`${getTableAlias(filterTableName)}.${property}`, value) + const alias = getTableAlias(filterTableName) + fn(alias ? `${alias}.${property}` : property, value) } } } const like = (key: string, value: any) => { - const fnc = allOr ? "orWhere" : "where" + const fuzzyOr = filters?.fuzzyOr + const fnc = fuzzyOr || allOr ? "orWhere" : "where" // postgres supports ilike, nothing else does if (this.client === SqlClient.POSTGRES) { query = query[fnc](key, "ilike", `%${value}%`) @@ -788,11 +786,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { return results.length ? results : [{ [operation.toLowerCase()]: true }] } - convertJsonStringColumns( + convertJsonStringColumns>( table: Table, - results: Record[], + results: T[], aliases?: Record - ): Record[] { + ): T[] { const tableName = getTableName(table) for (const [name, field] of Object.entries(table.schema)) { if (!this._isJsonColumn(field)) { @@ -801,11 +799,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { const aliasedTableName = (tableName && aliases?.[tableName]) || tableName const fullName = `${aliasedTableName}.${name}` for (let row of results) { - if (typeof row[fullName] === "string") { - row[fullName] = JSON.parse(row[fullName]) + if (typeof row[fullName as keyof T] === "string") { + row[fullName as keyof T] = JSON.parse(row[fullName]) } - if (typeof row[name] === "string") { - row[name] = JSON.parse(row[name]) + if (typeof row[name as keyof T] === "string") { + row[name as keyof T] = JSON.parse(row[name]) } } } @@ -816,9 +814,8 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { field: FieldSchema ): field is JsonFieldMetadata | BBReferenceFieldMetadata { return ( - field.type === FieldType.JSON || - (field.type === FieldType.BB_REFERENCE && - !helpers.schema.isDeprecatedSingleUserColumn(field)) + JsonTypes.includes(field.type) && + !helpers.schema.isDeprecatedSingleUserColumn(field) ) } diff --git a/packages/backend-core/src/sql/sqlStatements.ts b/packages/backend-core/src/sql/sqlStatements.ts new file mode 100644 index 0000000000..a80defd8b8 --- /dev/null +++ b/packages/backend-core/src/sql/sqlStatements.ts @@ -0,0 +1,79 @@ +import { FieldType, Table, FieldSchema, SqlClient } from "@budibase/types" +import { Knex } from "knex" + +export class SqlStatements { + client: string + table: Table + allOr: boolean | undefined + constructor( + client: string, + table: Table, + { allOr }: { allOr?: boolean } = {} + ) { + this.client = client + this.table = table + this.allOr = allOr + } + + getField(key: string): FieldSchema | undefined { + const fieldName = key.split(".")[1] + return this.table.schema[fieldName] + } + + between( + query: Knex.QueryBuilder, + key: string, + low: number | string, + high: number | string + ) { + // Use a between operator if we have 2 valid range values + const field = this.getField(key) + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw( + `CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`, + [low, high] + ) + } else { + const fnc = this.allOr ? "orWhereBetween" : "whereBetween" + query = query[fnc](key, [low, high]) + } + return query + } + + lte(query: Knex.QueryBuilder, key: string, low: number | string) { + // Use just a single greater than operator if we only have a low + const field = this.getField(key) + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [ + low, + ]) + } else { + const fnc = this.allOr ? "orWhere" : "where" + query = query[fnc](key, ">=", low) + } + return query + } + + gte(query: Knex.QueryBuilder, key: string, high: number | string) { + const field = this.getField(key) + // Use just a single less than operator if we only have a high + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [ + high, + ]) + } else { + const fnc = this.allOr ? "orWhere" : "where" + query = query[fnc](key, "<=", high) + } + return query + } +} diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/backend-core/src/sql/sqlTable.ts similarity index 99% rename from packages/server/src/integrations/base/sqlTable.ts rename to packages/backend-core/src/sql/sqlTable.ts index 66952f1b58..09f9908baa 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/backend-core/src/sql/sqlTable.ts @@ -9,8 +9,9 @@ import { SqlQuery, Table, TableSourceType, + SqlClient, } from "@budibase/types" -import { breakExternalTableId, getNativeSql, SqlClient } from "../utils" +import { breakExternalTableId, getNativeSql } from "./utils" import { helpers, utils } from "@budibase/shared-core" import SchemaBuilder = Knex.SchemaBuilder import CreateTableBuilder = Knex.CreateTableBuilder diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts new file mode 100644 index 0000000000..2d9b289417 --- /dev/null +++ b/packages/backend-core/src/sql/utils.ts @@ -0,0 +1,134 @@ +import { DocumentType, SqlQuery, Table, TableSourceType } from "@budibase/types" +import { DEFAULT_BB_DATASOURCE_ID } from "../constants" +import { Knex } from "knex" +import { SEPARATOR } from "../db" + +const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` +const ROW_ID_REGEX = /^\[.*]$/g +const ENCODED_SPACE = encodeURIComponent(" ") + +export function isExternalTableID(tableId: string) { + return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR) +} + +export function isInternalTableID(tableId: string) { + return !isExternalTableID(tableId) +} + +export function getNativeSql( + query: Knex.SchemaBuilder | Knex.QueryBuilder +): SqlQuery | SqlQuery[] { + let sql = query.toSQL() + if (Array.isArray(sql)) { + return sql as SqlQuery[] + } + let native: Knex.SqlNative | undefined + if (sql.toNative) { + native = sql.toNative() + } + return { + sql: native?.sql || sql.sql, + bindings: native?.bindings || sql.bindings, + } as SqlQuery +} + +export function isExternalTable(table: Table) { + if ( + table?.sourceId && + table.sourceId.includes(DocumentType.DATASOURCE + SEPARATOR) && + table?.sourceId !== DEFAULT_BB_DATASOURCE_ID + ) { + return true + } else if (table?.sourceType === TableSourceType.EXTERNAL) { + return true + } else if (table?._id && isExternalTableID(table._id)) { + return true + } + return false +} + +export function buildExternalTableId(datasourceId: string, tableName: string) { + // encode spaces + if (tableName.includes(" ")) { + tableName = encodeURIComponent(tableName) + } + return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}` +} + +export function breakExternalTableId(tableId: string | undefined) { + if (!tableId) { + return {} + } + const parts = tableId.split(DOUBLE_SEPARATOR) + let datasourceId = parts.shift() + // if they need joined + let tableName = parts.join(DOUBLE_SEPARATOR) + // if contains encoded spaces, decode it + if (tableName.includes(ENCODED_SPACE)) { + tableName = decodeURIComponent(tableName) + } + return { datasourceId, tableName } +} + +export function generateRowIdField(keyProps: any[] = []) { + if (!Array.isArray(keyProps)) { + keyProps = [keyProps] + } + for (let index in keyProps) { + if (keyProps[index] instanceof Buffer) { + keyProps[index] = keyProps[index].toString() + } + } + // this conserves order and types + // we have to swap the double quotes to single quotes for use in HBS statements + // when using the literal helper the double quotes can break things + return encodeURIComponent(JSON.stringify(keyProps).replace(/"/g, "'")) +} + +export function isRowId(field: any) { + return ( + Array.isArray(field) || + (typeof field === "string" && field.match(ROW_ID_REGEX) != null) + ) +} + +export function convertRowId(field: any) { + if (Array.isArray(field)) { + return field[0] + } + if (typeof field === "string" && field.match(ROW_ID_REGEX) != null) { + return field.substring(1, field.length - 1) + } + return field +} + +// should always return an array +export function breakRowIdField(_id: string | { _id: string }): any[] { + if (!_id) { + return [] + } + // have to replace on the way back as we swapped out the double quotes + // when encoding, but JSON can't handle the single quotes + const id = typeof _id === "string" ? _id : _id._id + const decoded: string = decodeURIComponent(id).replace(/'/g, '"') + try { + const parsed = JSON.parse(decoded) + return Array.isArray(parsed) ? parsed : [parsed] + } catch (err) { + // wasn't json - likely was handlebars for a many to many + return [_id] + } +} + +export function isIsoDateString(str: string) { + const trimmedValue = str.trim() + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test(trimmedValue)) { + return false + } + let d = new Date(trimmedValue) + return d.toISOString() === trimmedValue +} + +export function isValidFilter(value: any) { + return value != null && value !== "" +} diff --git a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte index decf77069f..91ec9a9b01 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte @@ -4,6 +4,8 @@ import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import { getUserBindings } from "dataBinding" import { makePropSafe } from "@budibase/string-templates" + import { search } from "@budibase/frontend-core" + import { tables } from "stores/builder" export let schema export let filters @@ -15,11 +17,10 @@ let drawer $: tempValue = filters || [] - $: schemaFields = Object.entries(schema || {}).map( - ([fieldName, fieldSchema]) => ({ - name: fieldName, // Using the key as name if not defined in the schema, for example in some autogenerated columns - ...fieldSchema, - }) + $: schemaFields = search.getFields( + $tables.list, + Object.values(schema || {}), + { allowLinks: true } ) $: text = getText(filters) diff --git a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte index 742ab785a1..4a11211662 100644 --- a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte @@ -1,11 +1,11 @@