diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 9da52f8bc0..a94c56407f 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -18,6 +18,8 @@ env: BRANCH: ${{ github.event.pull_request.head.ref }} BASE_BRANCH: ${{ github.event.pull_request.base.ref}} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + NX_BASE_BRANCH: origin/${{ github.base_ref }} + USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' && github.base_ref != 'master'}} jobs: lint: @@ -25,20 +27,20 @@ jobs: steps: - name: Checkout repo and submodules uses: actions/checkout@v3 - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Checkout repo only uses: actions/checkout@v3 - if: github.repository != 'Budibase/budibase' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase' - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn + - run: yarn --frozen-lockfile - run: yarn lint build: @@ -46,45 +48,70 @@ jobs: steps: - name: Checkout repo and submodules uses: actions/checkout@v3 - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + fetch-depth: 0 - name: Checkout repo only uses: actions/checkout@v3 - if: github.repository != 'Budibase/budibase' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase' + with: + fetch-depth: 0 - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn + - run: yarn --frozen-lockfile + # Run build all the projects - - run: yarn build + - name: Build + run: | + if ${{ env.USE_NX_AFFECTED }}; then + yarn build --since=${{ env.NX_BASE_BRANCH }} + else + yarn build + fi # Check the types of the projects built via esbuild - - run: yarn check:types + - name: Check types + run: | + if ${{ env.USE_NX_AFFECTED }}; then + yarn check:types --since=${{ env.NX_BASE_BRANCH }} + else + yarn check:types + fi test-libraries: runs-on: ubuntu-latest steps: - name: Checkout repo and submodules uses: actions/checkout@v3 - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + fetch-depth: 0 - name: Checkout repo only uses: actions/checkout@v3 - if: github.repository != 'Budibase/budibase' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase' + with: + fetch-depth: 0 - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn - - run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro + - run: yarn --frozen-lockfile + - name: Test + run: | + if ${{ env.USE_NX_AFFECTED }}; then + yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }} + else + yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro + fi - uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos @@ -96,21 +123,31 @@ jobs: steps: - name: Checkout repo and submodules uses: actions/checkout@v3 - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + fetch-depth: 0 - name: Checkout repo only uses: actions/checkout@v3 - if: github.repository != 'Budibase/budibase' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase' + with: + fetch-depth: 0 - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn - - run: yarn test --scope=@budibase/worker --scope=@budibase/server + - run: yarn --frozen-lockfile + - name: Test worker and server + run: | + if ${{ env.USE_NX_AFFECTED }}; then + yarn test --scope=@budibase/worker --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }} + else + yarn test --scope=@budibase/worker --scope=@budibase/server + fi + - uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos @@ -119,42 +156,49 @@ jobs: test-pro: runs-on: ubuntu-latest - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' steps: - name: Checkout repo and submodules uses: actions/checkout@v3 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + fetch-depth: 0 - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn - - run: yarn test --scope=@budibase/pro + - run: yarn --frozen-lockfile + - name: Test + run: | + if ${{ env.USE_NX_AFFECTED }}; then + yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }} + else + yarn test --scope=@budibase/pro + fi integration-test: runs-on: ubuntu-latest steps: - name: Checkout repo and submodules uses: actions/checkout@v3 - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Checkout repo only uses: actions/checkout@v3 - if: github.repository != 'Budibase/budibase' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase' - - name: Use Node.js 14.x + - name: Use Node.js 18.x uses: actions/setup-node@v3 with: - node-version: 14.x + node-version: 18.x cache: "yarn" - - run: yarn - - run: yarn build --projects=@budibase/server,@budibase/worker,@budibase/client + - run: yarn --frozen-lockfile + - run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client - name: Run tests run: | cd qa-core @@ -166,13 +210,12 @@ jobs: check-pro-submodule: runs-on: ubuntu-latest - if: github.repository == 'Budibase/budibase' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' steps: - name: Checkout repo and submodules uses: actions/checkout@v3 with: submodules: true - fetch-depth: 0 token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Check pro commit diff --git a/.github/workflows/check_unreleased_changes.yml b/.github/workflows/check_unreleased_changes.yml new file mode 100644 index 0000000000..d558330545 --- /dev/null +++ b/.github/workflows/check_unreleased_changes.yml @@ -0,0 +1,29 @@ +name: check_unreleased_changes + +on: + pull_request: + branches: + - master + +jobs: + check_unreleased: + runs-on: ubuntu-latest + steps: + - name: Check for unreleased changes + env: + REPO: "Budibase/budibase" + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RELEASE_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \ + "https://api.github.com/repos/$REPO/releases/latest" | \ + jq -r .published_at) + COMMIT_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \ + "https://api.github.com/repos/$REPO/commits/master" | \ + jq -r .commit.committer.date) + RELEASE_SECONDS=$(date --date="$RELEASE_TIMESTAMP" "+%s") + COMMIT_SECONDS=$(date --date="$COMMIT_TIMESTAMP" "+%s") + if (( COMMIT_SECONDS > RELEASE_SECONDS )); then + echo "There are unreleased changes. Please release these changes before merging." + exit 1 + fi + echo "No unreleased changes detected." diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 61cb283e28..bd727b7865 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: 14.x + node-version: 18.x - run: yarn install --frozen-lockfile - name: Update versions diff --git a/.nvmrc b/.nvmrc index 835d07c442..7950a44576 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14.20.1 +v18.17.0 diff --git a/.tool-versions b/.tool-versions index 9f2ea77b14..a909d60941 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -nodejs 14.21.3 +nodejs 18.17.0 python 3.10.0 -yarn 1.22.19 \ No newline at end of file +yarn 1.22.19 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2fb4c36fa8..70f198a84c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -90,7 +90,7 @@ Component libraries are collections of components as well as the definition of t #### 1. Prerequisites -- NodeJS version `14.x.x` +- NodeJS version `18.x.x` - Python version `3.x` ### Using asdf (recommended) diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index e43e5ad10c..6fc1f96d28 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-slim as build +FROM node:18-slim as build # install node-gyp dependencies RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python diff --git a/lerna.json b/lerna.json index 0df96afd31..84e3a6b634 100644 --- a/lerna.json +++ b/lerna.json @@ -1,9 +1,7 @@ { "version": "2.9.23", "npmClient": "yarn", - "packages": [ - "packages/*" - ], + "packages": ["packages/*"], "useNx": true, "command": { "publish": { @@ -19,4 +17,4 @@ "loadEnvFiles": false } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index d27af2e27d..4e4befb5f2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "preinstall": "node scripts/syncProPackage.js", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", - "build": "yarn nx run-many -t=build", + "build": "lerna run build --stream", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", "check:types": "lerna run check:types", "backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap", @@ -109,7 +109,7 @@ "@budibase/types": "0.0.0" }, "engines": { - "node": ">=14.0.0 <15.0.0" + "node": ">=18.0.0 <19.0.0" }, "dependencies": {} } diff --git a/packages/backend-core/src/security/permissions.ts b/packages/backend-core/src/security/permissions.ts index 70dae57ae6..aa0b20a30c 100644 --- a/packages/backend-core/src/security/permissions.ts +++ b/packages/backend-core/src/security/permissions.ts @@ -78,7 +78,6 @@ export const BUILTIN_PERMISSIONS = { permissions: [ new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ), - new Permission(PermissionType.VIEW, PermissionLevel.READ), ], }, WRITE: { @@ -87,7 +86,6 @@ export const BUILTIN_PERMISSIONS = { permissions: [ new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE), - new Permission(PermissionType.VIEW, PermissionLevel.READ), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), ], }, @@ -98,7 +96,6 @@ export const BUILTIN_PERMISSIONS = { new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), - new Permission(PermissionType.VIEW, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), ], }, @@ -109,7 +106,6 @@ export const BUILTIN_PERMISSIONS = { new Permission(PermissionType.TABLE, PermissionLevel.ADMIN), new Permission(PermissionType.USER, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), - new Permission(PermissionType.VIEW, PermissionLevel.ADMIN), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), ], diff --git a/packages/backend-core/tests/core/utilities/testContainerUtils.ts b/packages/backend-core/tests/core/utilities/testContainerUtils.ts index f6c702f7ef..06bd91392e 100644 --- a/packages/backend-core/tests/core/utilities/testContainerUtils.ts +++ b/packages/backend-core/tests/core/utilities/testContainerUtils.ts @@ -80,7 +80,7 @@ function getRedisConfig() { export function setupEnv(...envs: any[]) { const couch = getCouchConfig(), - minio = getCouchConfig(), + minio = getMinioConfig(), redis = getRedisConfig() const configs = [ { key: "COUCH_DB_PORT", value: couch.port }, diff --git a/packages/pro b/packages/pro index 02626390cd..9b9c8cc08f 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 02626390cde905a248cb60729968667c9e49fae9 +Subproject commit 9b9c8cc08f271bfc5dd401860f344f6eb336ab35 diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index a526538b71..61f064ecfb 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-slim +FROM node:18-slim LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="scripts/watchtower-hooks/pre-check.sh" LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="scripts/watchtower-hooks/pre-update.sh" diff --git a/packages/server/scripts/test.sh b/packages/server/scripts/test.sh index bc21ec53ed..990d534d8c 100644 --- a/packages/server/scripts/test.sh +++ b/packages/server/scripts/test.sh @@ -5,8 +5,9 @@ if [[ -n $CI ]] then # --runInBand performs better in ci where resources are limited export NODE_OPTIONS="--max-old-space-size=4096" - echo "jest --coverage --runInBand --forceExit --bail" - jest --coverage --runInBand --forceExit --bail + node ../../node_modules/jest/bin/jest.js --version + echo "jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail" + jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail else # --maxWorkers performs better in development echo "jest --coverage --maxWorkers=2 --forceExit" diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index 3ee4ca6edd..bc4edbd661 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -15,6 +15,7 @@ import { UserCtx, } from "@budibase/types" import sdk from "../../../sdk" +import * as utils from "./utils" export async function handleRequest( operation: Operation, @@ -43,7 +44,7 @@ export async function handleRequest( } export async function patch(ctx: UserCtx) { - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const { _id, ...rowData } = ctx.request.body const validateResult = await sdk.rows.utils.validate({ @@ -70,7 +71,7 @@ export async function patch(ctx: UserCtx) { export async function save(ctx: UserCtx) { const inputs = ctx.request.body - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const validateResult = await sdk.rows.utils.validate({ row: inputs, tableId, @@ -98,12 +99,12 @@ export async function save(ctx: UserCtx) { export async function find(ctx: UserCtx) { const id = ctx.params.rowId - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) return sdk.rows.external.getRow(tableId, id) } export async function destroy(ctx: UserCtx) { - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const _id = ctx.request.body._id const { row } = (await handleRequest(Operation.DELETE, tableId, { id: breakRowIdField(_id), @@ -114,7 +115,7 @@ export async function destroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) { const { rows } = ctx.request.body - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) let promises: Promise[] = [] for (let row of rows) { promises.push( @@ -130,7 +131,7 @@ export async function bulkDestroy(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) { const id = ctx.params.rowId - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const { datasourceId, tableName } = breakExternalTableId(tableId) const datasource: Datasource = await sdk.datasources.get(datasourceId!) if (!tableName) { diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 7f6f494621..695e626630 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -11,6 +11,9 @@ import { Row, PatchRowRequest, PatchRowResponse, + SearchRowResponse, + SearchRowRequest, + SearchParams, } from "@budibase/types" import * as utils from "./utils" import { gridSocket } from "../../../websockets" @@ -197,10 +200,10 @@ export async function destroy(ctx: UserCtx) { ctx.body = response } -export async function search(ctx: any) { +export async function search(ctx: Ctx) { const tableId = utils.getTableId(ctx) - const searchParams = { + const searchParams: SearchParams = { ...ctx.request.body, tableId, } diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index 2ff1df0933..3432ec80f3 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -13,7 +13,7 @@ import { import { FieldTypes } from "../../../constants" import * as utils from "./utils" import { cloneDeep } from "lodash/fp" -import { context, db as dbCore } from "@budibase/backend-core" +import { context } from "@budibase/backend-core" import { finaliseRow, updateRelatedFormula } from "./staticFormula" import { UserCtx, @@ -26,8 +26,8 @@ import { import sdk from "../../../sdk" export async function patch(ctx: UserCtx) { + const tableId = utils.getTableId(ctx) const inputs = ctx.request.body - const tableId = inputs.tableId const isUserTable = tableId === InternalTables.USER_METADATA let oldRow const dbTable = await sdk.tables.getTable(tableId) @@ -94,7 +94,8 @@ export async function patch(ctx: UserCtx) { export async function save(ctx: UserCtx) { let inputs = ctx.request.body - inputs.tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) + inputs.tableId = tableId if (!inputs._rev && !inputs._id) { inputs._id = generateRowID(inputs.tableId) @@ -132,20 +133,22 @@ export async function save(ctx: UserCtx) { } export async function find(ctx: UserCtx) { - const db = dbCore.getDB(ctx.appId) - const table = await sdk.tables.getTable(ctx.params.tableId) - let row = await utils.findRow(ctx, ctx.params.tableId, ctx.params.rowId) + const tableId = utils.getTableId(ctx), + rowId = ctx.params.rowId + const table = await sdk.tables.getTable(tableId) + let row = await utils.findRow(ctx, tableId, rowId) row = await outputProcessing(table, row) return row } export async function destroy(ctx: UserCtx) { const db = context.getAppDB() + const tableId = utils.getTableId(ctx) const { _id } = ctx.request.body let row = await db.get(_id) let _rev = ctx.request.body._rev || row._rev - if (row.tableId !== ctx.params.tableId) { + if (row.tableId !== tableId) { throw "Supplied tableId doesn't match the row's tableId" } const table = await sdk.tables.getTable(row.tableId) @@ -163,7 +166,7 @@ export async function destroy(ctx: UserCtx) { await updateRelatedFormula(table, row) let response - if (ctx.params.tableId === InternalTables.USER_METADATA) { + if (tableId === InternalTables.USER_METADATA) { ctx.params = { id: _id, } @@ -176,7 +179,7 @@ export async function destroy(ctx: UserCtx) { } export async function bulkDestroy(ctx: UserCtx) { - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const table = await sdk.tables.getTable(tableId) let { rows } = ctx.request.body @@ -216,7 +219,7 @@ export async function bulkDestroy(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) { const db = context.getAppDB() - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const rowId = ctx.params.rowId // need table to work out where links go in row let [table, row] = await Promise.all([ diff --git a/packages/server/src/api/controllers/row/utils.ts b/packages/server/src/api/controllers/row/utils.ts index 6ff90f2b25..e85ec4553c 100644 --- a/packages/server/src/api/controllers/row/utils.ts +++ b/packages/server/src/api/controllers/row/utils.ts @@ -45,13 +45,20 @@ export async function findRow(ctx: UserCtx, tableId: string, rowId: string) { } export function getTableId(ctx: Ctx) { - if (ctx.request.body && ctx.request.body.tableId) { - return ctx.request.body.tableId + // top priority, use the URL first + if (ctx.params?.sourceId) { + return ctx.params.sourceId } - if (ctx.params && ctx.params.tableId) { + // now check for old way of specifying table ID + if (ctx.params?.tableId) { return ctx.params.tableId } - if (ctx.params && ctx.params.viewName) { + // check body for a table ID + if (ctx.request.body?.tableId) { + return ctx.request.body.tableId + } + // now check if a specific view name + if (ctx.params?.viewName) { return ctx.params.viewName } } diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index c38b7fe56e..c3b716ef88 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -1,14 +1,18 @@ import { quotas } from "@budibase/pro" import { UserCtx, - SearchResponse, - SortOrder, - SortType, ViewV2, + SearchRowResponse, + SearchViewRowRequest, + RequiredKeys, + SearchParams, } from "@budibase/types" +import { dataFilters } from "@budibase/shared-core" import sdk from "../../../sdk" -export async function searchView(ctx: UserCtx) { +export async function searchView( + ctx: UserCtx +) { const { viewId } = ctx.params const view = await sdk.views.get(viewId) @@ -29,49 +33,35 @@ export async function searchView(ctx: UserCtx) { undefined ctx.status = 200 - const result = await quotas.addQuery( - () => - sdk.rows.search({ - tableId: view.tableId, - query: view.query || {}, - fields: viewFields, - ...getSortOptions(ctx, view), - }), - { - datasourceId: view.tableId, - } - ) + + const { body } = ctx.request + const query = dataFilters.buildLuceneQuery(view.query || []) + + const searchOptions: RequiredKeys & + RequiredKeys> = { + tableId: view.tableId, + query, + fields: viewFields, + ...getSortOptions(body, view), + limit: body.limit, + bookmark: body.bookmark, + paginate: body.paginate, + } + + const result = await quotas.addQuery(() => sdk.rows.search(searchOptions), { + datasourceId: view.tableId, + }) result.rows.forEach(r => (r._viewId = view.id)) ctx.body = result } -function getSortOptions( - ctx: UserCtx, - view: ViewV2 -): - | { - sort: string - sortOrder?: SortOrder - sortType?: SortType - } - | undefined { - const { sort_column, sort_order, sort_type } = ctx.query - if (Array.isArray(sort_column)) { - ctx.throw(400, "sort_column cannot be an array") - } - if (Array.isArray(sort_order)) { - ctx.throw(400, "sort_order cannot be an array") - } - if (Array.isArray(sort_type)) { - ctx.throw(400, "sort_type cannot be an array") - } - - if (sort_column) { +function getSortOptions(request: SearchViewRowRequest, view: ViewV2) { + if (request.sort) { return { - sort: sort_column, - sortOrder: sort_order as SortOrder, - sortType: sort_type as SortType, + sort: request.sort, + sortOrder: request.sortOrder, + sortType: request.sortType, } } if (view.sort) { @@ -82,5 +72,9 @@ function getSortOptions( } } - return + return { + sort: undefined, + sortOrder: undefined, + sortType: undefined, + } } diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index ac0cd2b4a4..a4ac8aa3ee 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -4,16 +4,14 @@ import authorized from "../../middleware/authorized" import { paramResource, paramSubResource } from "../../middleware/resourceId" import { permissions } from "@budibase/backend-core" import { internalSearchValidator } from "./utils/validators" -import noViewData from "../../middleware/noViewData" import trimViewRowInfo from "../../middleware/trimViewRowInfo" -import * as utils from "../../db/utils" const { PermissionType, PermissionLevel } = permissions const router: Router = new Router() router /** - * @api {get} /api/:tableId/:rowId/enrich Get an enriched row + * @api {get} /api/:sourceId/:rowId/enrich Get an enriched row * @apiName Get an enriched row * @apiGroup rows * @apiPermission table read access @@ -27,13 +25,13 @@ router * @apiSuccess {object} row The response body will be the enriched row. */ .get( - "/api/:tableId/:rowId/enrich", - paramSubResource("tableId", "rowId"), + "/api/:sourceId/:rowId/enrich", + paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetchEnrichedRow ) /** - * @api {get} /api/:tableId/rows Get all rows in a table + * @api {get} /api/:sourceId/rows Get all rows in a table * @apiName Get all rows in a table * @apiGroup rows * @apiPermission table read access @@ -42,37 +40,37 @@ router * due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then * will simply stop. * - * @apiParam {string} tableId The ID of the table to retrieve all rows within. + * @apiParam {string} sourceId The ID of the table to retrieve all rows within. * * @apiSuccess {object[]} rows The response body will be an array of all rows found. */ .get( - "/api/:tableId/rows", - paramResource("tableId"), + "/api/:sourceId/rows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetch ) /** - * @api {get} /api/:tableId/rows/:rowId Retrieve a single row + * @api {get} /api/:sourceId/rows/:rowId Retrieve a single row * @apiName Retrieve a single row * @apiGroup rows * @apiPermission table read access * @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve * a row by anything other than its _id field, use the search endpoint. * - * @apiParam {string} tableId The ID of the table to retrieve a row from. + * @apiParam {string} sourceId The ID of the table to retrieve a row from. * @apiParam {string} rowId The ID of the row to retrieve. * * @apiSuccess {object} body The response body will be the row that was found. */ .get( - "/api/:tableId/rows/:rowId", - paramSubResource("tableId", "rowId"), + "/api/:sourceId/rows/:rowId", + paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.find ) /** - * @api {post} /api/:tableId/search Search for rows in a table + * @api {post} /api/:sourceId/search Search for rows in a table * @apiName Search for rows in a table * @apiGroup rows * @apiPermission table read access @@ -80,7 +78,7 @@ router * and data UI in the builder are built atop this. All filtering, sorting and pagination is * handled through this, for internal and external (datasource plus, e.g. SQL) tables. * - * @apiParam {string} tableId The ID of the table to retrieve rows from. + * @apiParam {string} sourceId The ID of the table to retrieve rows from. * * @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true, * defaults to false. @@ -135,22 +133,22 @@ router * page. */ .post( - "/api/:tableId/search", + "/api/:sourceId/search", internalSearchValidator(), - paramResource("tableId"), + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) // DEPRECATED - this is an old API, but for backwards compat it needs to be // supported still .post( - "/api/search/:tableId/rows", - paramResource("tableId"), + "/api/search/:sourceId/rows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) /** - * @api {post} /api/:tableId/rows Creates a new row + * @api {post} /api/:sourceId/rows Creates a new row * @apiName Creates a new row * @apiGroup rows * @apiPermission table write access @@ -159,7 +157,7 @@ router * links to one. Please note that "_id", "_rev" and "tableId" are fields that are * already used by Budibase tables and cannot be used for columns. * - * @apiParam {string} tableId The ID of the table to save a row to. + * @apiParam {string} sourceId The ID of the table to save a row to. * * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided. * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision @@ -174,14 +172,14 @@ router * @apiSuccess {object} body The contents of the row that was saved will be returned as well. */ .post( - "/api/:tableId/rows", - paramResource("tableId"), + "/api/:sourceId/rows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), - noViewData, + trimViewRowInfo, rowController.save ) /** - * @api {patch} /api/:tableId/rows Updates a row + * @api {patch} /api/:sourceId/rows Updates a row * @apiName Update a row * @apiGroup rows * @apiPermission table write access @@ -189,14 +187,14 @@ router * error if an _id isn't provided, it will only function for existing rows. */ .patch( - "/api/:tableId/rows", - paramResource("tableId"), + "/api/:sourceId/rows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), - noViewData, + trimViewRowInfo, rowController.patch ) /** - * @api {post} /api/:tableId/rows/validate Validate inputs for a row + * @api {post} /api/:sourceId/rows/validate Validate inputs for a row * @apiName Validate inputs for a row * @apiGroup rows * @apiPermission table write access @@ -204,7 +202,7 @@ router * given the table schema, this will iterate through all the constraints on the table and * check if the request body is valid. * - * @apiParam {string} tableId The ID of the table the row is to be validated for. + * @apiParam {string} sourceId The ID of the table the row is to be validated for. * * @apiParam (Body) {any} [any] Any fields provided in the request body will be tested * against the table schema and constraints. @@ -216,20 +214,20 @@ router * the schema. */ .post( - "/api/:tableId/rows/validate", - paramResource("tableId"), + "/api/:sourceId/rows/validate", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), rowController.validate ) /** - * @api {delete} /api/:tableId/rows Delete rows + * @api {delete} /api/:sourceId/rows Delete rows * @apiName Delete rows * @apiGroup rows * @apiPermission table write access * @apiDescription This endpoint can delete a single row, or delete them in a bulk * fashion. * - * @apiParam {string} tableId The ID of the table the row is to be deleted from. + * @apiParam {string} sourceId The ID of the table the row is to be deleted from. * * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this * key of the request body that are to be deleted. @@ -242,117 +240,37 @@ router * is the deleted row. */ .delete( - "/api/:tableId/rows", - paramResource("tableId"), + "/api/:sourceId/rows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), + trimViewRowInfo, rowController.destroy ) /** - * @api {post} /api/:tableId/rows/exportRows Export Rows + * @api {post} /api/:sourceId/rows/exportRows Export Rows * @apiName Export rows * @apiGroup rows * @apiPermission table write access * @apiDescription This API can export a number of provided rows * - * @apiParam {string} tableId The ID of the table the row is to be deleted from. + * @apiParam {string} sourceId The ID of the table the row is to be deleted from. * * @apiParam (Body) {object[]} [rows] The row IDs which are to be exported * * @apiSuccess {object[]|object} */ .post( - "/api/:tableId/rows/exportRows", - paramResource("tableId"), + "/api/:sourceId/rows/exportRows", + paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), rowController.exportRows ) -router - .get( - "/api/v2/views/:viewId/search", - authorized(PermissionType.VIEW, PermissionLevel.READ), - rowController.views.searchView - ) - /** - * @api {post} /api/:tableId/rows Creates a new row - * @apiName Creates a new row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This API will create a new row based on the supplied body. If the - * body includes an "_id" field then it will update an existing row if the field - * links to one. Please note that "_id", "_rev" and "tableId" are fields that are - * already used by Budibase tables and cannot be used for columns. - * - * @apiParam {string} tableId The ID of the table to save a row to. - * - * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided. - * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision - * must also be provided. - * @apiParam (Body) {string} _viewId The ID of the view should be specified in the row body itself. - * @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself. - * @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches - * a column in the specified table. All other fields will be dropped and not stored. - * - * @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this - * is the rows new ID. - * @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned. - * @apiSuccess {object} body The contents of the row that was saved will be returned as well. - */ - .post( - "/api/v2/views/:viewId/rows", - paramResource("viewId"), - authorized(PermissionType.VIEW, PermissionLevel.WRITE), - trimViewRowInfo, - rowController.save - ) - /** - * @api {patch} /api/v2/views/:viewId/rows/:rowId Updates a row - * @apiName Update a row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint is identical to the row creation endpoint but instead it will - * error if an _id isn't provided, it will only function for existing rows. - */ - .patch( - "/api/v2/views/:viewId/rows/:rowId", - paramResource("viewId"), - authorized(PermissionType.VIEW, PermissionLevel.WRITE), - trimViewRowInfo, - rowController.patch - ) - /** - * @api {delete} /api/v2/views/:viewId/rows Delete rows for a view - * @apiName Delete rows for a view - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint can delete a single row, or delete them in a bulk - * fashion. - * - * @apiParam {string} tableId The ID of the table the row is to be deleted from. - * - * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this - * key of the request body that are to be deleted. - * @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field. - * @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its - * revision here. - * - * @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array - * of the deleted rows, if deleting a single row then the body will contain a "row" property which - * is the deleted row. - */ - .delete( - "/api/v2/views/:viewId/rows", - paramResource("viewId"), - authorized(PermissionType.VIEW, PermissionLevel.WRITE), - // This is required as the implementation relies on the table id - (ctx, next) => { - ctx.params.tableId = utils.extractViewInfoFromID( - ctx.params.viewId - ).tableId - return next() - }, - rowController.destroy - ) +router.post( + "/api/v2/views/:viewId/search", + authorized(PermissionType.TABLE, PermissionLevel.READ), + rowController.views.searchView +) export default router diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index b986ffb945..8d4c9a91fd 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -16,16 +16,12 @@ import { FieldType, SortType, SortOrder, - DeleteRow, } from "@budibase/types" import { expectAnyInternalColsAttributes, generator, structures, } from "@budibase/backend-core/tests" -import trimViewRowInfoMiddleware from "../../../middleware/trimViewRowInfo" -import noViewDataMiddleware from "../../../middleware/noViewData" -import router from "../row" describe("/rows", () => { let request = setup.getRequest() @@ -394,26 +390,6 @@ describe("/rows", () => { expect(saved.arrayFieldArrayStrKnown).toEqual(["One"]) expect(saved.optsFieldStrKnown).toEqual("Alpha") }) - - it("should throw an error when creating a table row with view id data", async () => { - const res = await request - .post(`/api/${row.tableId}/rows`) - .send({ ...row, _viewId: generator.guid() }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(400) - expect(res.body.message).toEqual( - "Table row endpoints cannot contain view info" - ) - }) - - it("should setup the noViewData middleware", async () => { - const route = router.stack.find( - r => r.methods.includes("POST") && r.path === "/api/:tableId/rows" - ) - expect(route).toBeDefined() - expect(route?.stack).toContainEqual(noViewDataMiddleware) - }) }) describe("patch", () => { @@ -463,33 +439,6 @@ describe("/rows", () => { await assertRowUsage(rowUsage) await assertQueryUsage(queryUsage) }) - - it("should throw an error when creating a table row with view id data", async () => { - const existing = await config.createRow() - - const res = await config.api.row.patch( - table._id!, - { - ...existing, - _id: existing._id!, - _rev: existing._rev!, - tableId: table._id!, - _viewId: generator.guid(), - }, - { expectStatus: 400 } - ) - expect(res.body.message).toEqual( - "Table row endpoints cannot contain view info" - ) - }) - - it("should setup the noViewData middleware", async () => { - const route = router.stack.find( - r => r.methods.includes("PATCH") && r.path === "/api/:tableId/rows" - ) - expect(route).toBeDefined() - expect(route?.stack).toContainEqual(noViewDataMiddleware) - }) }) describe("destroy", () => { @@ -758,7 +707,7 @@ describe("/rows", () => { }) // the environment needs configured for this await setup.switchToSelfHosted(async () => { - context.doInAppContext(config.getAppId(), async () => { + return context.doInAppContext(config.getAppId(), async () => { const enriched = await outputProcessing(table, [row]) expect((enriched as Row[])[0].attachment[0].url).toBe( `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` @@ -813,252 +762,6 @@ describe("/rows", () => { }) }) - describe("view search", () => { - function userTable(): Table { - return { - name: "user", - type: "user", - schema: { - name: { - type: FieldType.STRING, - name: "name", - constraints: { type: "string" }, - }, - age: { - type: FieldType.NUMBER, - name: "age", - constraints: {}, - }, - }, - } - } - - it("returns table rows from view", async () => { - const table = await config.createTable(userTable()) - const rows = [] - for (let i = 0; i < 10; i++) { - rows.push(await config.createRow({ tableId: table._id })) - } - - const createViewResponse = await config.api.viewV2.create() - const response = await config.api.viewV2.search(createViewResponse.id) - - expect(response.body.rows).toHaveLength(10) - expect(response.body).toEqual({ - rows: expect.arrayContaining(rows.map(expect.objectContaining)), - }) - }) - - it("searching respects the view filters", async () => { - const table = await config.createTable(userTable()) - const expectedRows = [] - for (let i = 0; i < 10; i++) - await config.createRow({ - tableId: table._id, - name: generator.name(), - age: generator.integer({ min: 10, max: 30 }), - }) - - for (let i = 0; i < 5; i++) - expectedRows.push( - await config.createRow({ - tableId: table._id, - name: generator.name(), - age: 40, - }) - ) - - const createViewResponse = await config.api.viewV2.create({ - query: { equal: { age: 40 } }, - }) - - const response = await config.api.viewV2.search(createViewResponse.id) - - expect(response.body.rows).toHaveLength(5) - expect(response.body).toEqual({ - rows: expect.arrayContaining(expectedRows.map(expect.objectContaining)), - }) - }) - - const sortTestOptions: [ - { - field: string - order?: SortOrder - type?: SortType - }, - string[] - ][] = [ - [ - { - field: "name", - order: SortOrder.ASCENDING, - type: SortType.STRING, - }, - ["Alice", "Bob", "Charly", "Danny"], - ], - [ - { - field: "name", - }, - ["Alice", "Bob", "Charly", "Danny"], - ], - [ - { - field: "name", - order: SortOrder.DESCENDING, - }, - ["Danny", "Charly", "Bob", "Alice"], - ], - [ - { - field: "name", - order: SortOrder.DESCENDING, - type: SortType.STRING, - }, - ["Danny", "Charly", "Bob", "Alice"], - ], - [ - { - field: "age", - order: SortOrder.ASCENDING, - type: SortType.number, - }, - ["Danny", "Alice", "Charly", "Bob"], - ], - [ - { - field: "age", - order: SortOrder.ASCENDING, - }, - ["Danny", "Alice", "Charly", "Bob"], - ], - [ - { - field: "age", - order: SortOrder.DESCENDING, - }, - ["Bob", "Charly", "Alice", "Danny"], - ], - [ - { - field: "age", - order: SortOrder.DESCENDING, - type: SortType.number, - }, - ["Bob", "Charly", "Alice", "Danny"], - ], - ] - - it.each(sortTestOptions)( - "allow sorting (%s)", - async (sortParams, expected) => { - await config.createTable(userTable()) - const users = [ - { name: "Alice", age: 25 }, - { name: "Bob", age: 30 }, - { name: "Charly", age: 27 }, - { name: "Danny", age: 15 }, - ] - for (const user of users) { - await config.createRow({ - tableId: config.table!._id, - ...user, - }) - } - - const createViewResponse = await config.api.viewV2.create({ - sort: sortParams, - }) - - const response = await config.api.viewV2.search(createViewResponse.id) - - expect(response.body.rows).toHaveLength(4) - expect(response.body).toEqual({ - rows: expected.map(name => expect.objectContaining({ name })), - }) - } - ) - - it.each(sortTestOptions)( - "allow override the default view sorting (%s)", - async (sortParams, expected) => { - await config.createTable(userTable()) - const users = [ - { name: "Alice", age: 25 }, - { name: "Bob", age: 30 }, - { name: "Charly", age: 27 }, - { name: "Danny", age: 15 }, - ] - for (const user of users) { - await config.createRow({ - tableId: config.table!._id, - ...user, - }) - } - - const createViewResponse = await config.api.viewV2.create({ - sort: { - field: "name", - order: SortOrder.ASCENDING, - type: SortType.STRING, - }, - }) - - const response = await config.api.viewV2.search(createViewResponse.id, { - sort: { - column: sortParams.field, - order: sortParams.order, - type: sortParams.type, - }, - }) - - expect(response.body.rows).toHaveLength(4) - expect(response.body).toEqual({ - rows: expected.map(name => expect.objectContaining({ name })), - }) - } - ) - - it("when schema is defined, defined columns and row attributes are returned", async () => { - const table = await config.createTable(userTable()) - const rows = [] - for (let i = 0; i < 10; i++) { - rows.push( - await config.createRow({ - tableId: table._id, - name: generator.name(), - age: generator.age(), - }) - ) - } - - const view = await config.api.viewV2.create({ - schema: { name: {} }, - }) - const response = await config.api.viewV2.search(view.id) - - expect(response.body.rows).toHaveLength(10) - expect(response.body.rows).toEqual( - expect.arrayContaining( - rows.map(r => ({ - ...expectAnyInternalColsAttributes, - _viewId: view.id, - name: r.name, - })) - ) - ) - }) - - it("views without data can be returned", async () => { - const table = await config.createTable(userTable()) - - const createViewResponse = await config.api.viewV2.create() - const response = await config.api.viewV2.search(createViewResponse.id) - - expect(response.body.rows).toHaveLength(0) - }) - }) - describe("view 2.0", () => { function userTable(): Table { return { @@ -1110,7 +813,7 @@ describe("/rows", () => { }) const data = randomRowData() - const newRow = await config.api.viewV2.row.create(view.id, { + const newRow = await config.api.row.save(view.id, { tableId: config.table!._id, _viewId: view.id, ...data, @@ -1132,16 +835,6 @@ describe("/rows", () => { expect(row.body.age).toBeUndefined() expect(row.body.jobTitle).toBeUndefined() }) - - it("should setup the trimViewRowInfo middleware", async () => { - const route = router.stack.find( - r => - r.methods.includes("POST") && - r.path === "/api/v2/views/:viewId/rows" - ) - expect(route).toBeDefined() - expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware) - }) }) describe("patch", () => { @@ -1156,13 +849,13 @@ describe("/rows", () => { }, }) - const newRow = await config.api.viewV2.row.create(view.id, { + const newRow = await config.api.row.save(view.id, { tableId, _viewId: view.id, ...randomRowData(), }) const newData = randomRowData() - await config.api.viewV2.row.update(view.id, newRow._id!, { + await config.api.row.patch(view.id, { tableId, _viewId: view.id, _id: newRow._id!, @@ -1185,16 +878,6 @@ describe("/rows", () => { expect(row.body.age).toBeUndefined() expect(row.body.jobTitle).toBeUndefined() }) - - it("should setup the trimViewRowInfo middleware", async () => { - const route = router.stack.find( - r => - r.methods.includes("PATCH") && - r.path === "/api/v2/views/:viewId/rows/:rowId" - ) - expect(route).toBeDefined() - expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware) - }) }) describe("destroy", () => { @@ -1213,10 +896,7 @@ describe("/rows", () => { const rowUsage = await getRowUsage() const queryUsage = await getQueryUsage() - const body: DeleteRow = { - _id: createdRow._id!, - } - await config.api.viewV2.row.delete(view.id, body) + await config.api.row.delete(view.id, [createdRow]) await assertRowUsage(rowUsage - 1) await assertQueryUsage(queryUsage + 1) @@ -1245,9 +925,7 @@ describe("/rows", () => { const rowUsage = await getRowUsage() const queryUsage = await getQueryUsage() - await config.api.viewV2.row.delete(view.id, { - rows: [rows[0], rows[2]], - }) + await config.api.row.delete(view.id, [rows[0], rows[2]]) await assertRowUsage(rowUsage - 2) await assertQueryUsage(queryUsage + 1) @@ -1261,5 +939,327 @@ describe("/rows", () => { await config.api.row.get(tableId, rows[1]._id!, { expectStatus: 200 }) }) }) + + describe("view search", () => { + function userTable(): Table { + return { + name: "user", + type: "user", + schema: { + name: { + type: FieldType.STRING, + name: "name", + constraints: { type: "string" }, + }, + age: { + type: FieldType.NUMBER, + name: "age", + constraints: {}, + }, + }, + } + } + + it("returns table rows from view", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push(await config.createRow({ tableId: table._id })) + } + + const createViewResponse = await config.api.viewV2.create() + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(10) + expect(response.body).toEqual({ + rows: expect.arrayContaining(rows.map(expect.objectContaining)), + }) + }) + + it("searching respects the view filters", async () => { + const table = await config.createTable(userTable()) + const expectedRows = [] + for (let i = 0; i < 10; i++) + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: generator.integer({ min: 10, max: 30 }), + }) + + for (let i = 0; i < 5; i++) + expectedRows.push( + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: 40, + }) + ) + + const createViewResponse = await config.api.viewV2.create({ + query: [{ operator: "equal", field: "age", value: 40 }], + }) + + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(5) + expect(response.body).toEqual({ + rows: expect.arrayContaining( + expectedRows.map(expect.objectContaining) + ), + }) + }) + + const sortTestOptions: [ + { + field: string + order?: SortOrder + type?: SortType + }, + string[] + ][] = [ + [ + { + field: "name", + order: SortOrder.ASCENDING, + type: SortType.STRING, + }, + ["Alice", "Bob", "Charly", "Danny"], + ], + [ + { + field: "name", + }, + ["Alice", "Bob", "Charly", "Danny"], + ], + [ + { + field: "name", + order: SortOrder.DESCENDING, + }, + ["Danny", "Charly", "Bob", "Alice"], + ], + [ + { + field: "name", + order: SortOrder.DESCENDING, + type: SortType.STRING, + }, + ["Danny", "Charly", "Bob", "Alice"], + ], + [ + { + field: "age", + order: SortOrder.ASCENDING, + type: SortType.number, + }, + ["Danny", "Alice", "Charly", "Bob"], + ], + [ + { + field: "age", + order: SortOrder.ASCENDING, + }, + ["Danny", "Alice", "Charly", "Bob"], + ], + [ + { + field: "age", + order: SortOrder.DESCENDING, + }, + ["Bob", "Charly", "Alice", "Danny"], + ], + [ + { + field: "age", + order: SortOrder.DESCENDING, + type: SortType.number, + }, + ["Bob", "Charly", "Alice", "Danny"], + ], + ] + + it.each(sortTestOptions)( + "allow sorting (%s)", + async (sortParams, expected) => { + await config.createTable(userTable()) + const users = [ + { name: "Alice", age: 25 }, + { name: "Bob", age: 30 }, + { name: "Charly", age: 27 }, + { name: "Danny", age: 15 }, + ] + for (const user of users) { + await config.createRow({ + tableId: config.table!._id, + ...user, + }) + } + + const createViewResponse = await config.api.viewV2.create({ + sort: sortParams, + }) + + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(4) + expect(response.body).toEqual({ + rows: expected.map(name => expect.objectContaining({ name })), + }) + } + ) + + it.each(sortTestOptions)( + "allow override the default view sorting (%s)", + async (sortParams, expected) => { + await config.createTable(userTable()) + const users = [ + { name: "Alice", age: 25 }, + { name: "Bob", age: 30 }, + { name: "Charly", age: 27 }, + { name: "Danny", age: 15 }, + ] + for (const user of users) { + await config.createRow({ + tableId: config.table!._id, + ...user, + }) + } + + const createViewResponse = await config.api.viewV2.create({ + sort: { + field: "name", + order: SortOrder.ASCENDING, + type: SortType.STRING, + }, + }) + + const response = await config.api.viewV2.search( + createViewResponse.id, + { + sort: sortParams.field, + sortOrder: sortParams.order, + sortType: sortParams.type, + } + ) + + expect(response.body.rows).toHaveLength(4) + expect(response.body).toEqual({ + rows: expected.map(name => expect.objectContaining({ name })), + }) + } + ) + + it("when schema is defined, defined columns and row attributes are returned", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push( + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: generator.age(), + }) + ) + } + + const view = await config.api.viewV2.create({ + schema: { name: {} }, + }) + const response = await config.api.viewV2.search(view.id) + + expect(response.body.rows).toHaveLength(10) + expect(response.body.rows).toEqual( + expect.arrayContaining( + rows.map(r => ({ + ...expectAnyInternalColsAttributes, + _viewId: view.id, + name: r.name, + })) + ) + ) + }) + + it("views without data can be returned", async () => { + const table = await config.createTable(userTable()) + + const createViewResponse = await config.api.viewV2.create() + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(0) + }) + + it("respects the limit parameter", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push(await config.createRow({ tableId: table._id })) + } + const limit = generator.integer({ min: 1, max: 8 }) + + const createViewResponse = await config.api.viewV2.create() + const response = await config.api.viewV2.search(createViewResponse.id, { + limit, + }) + + expect(response.body.rows).toHaveLength(limit) + }) + + it("can handle pagination", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push(await config.createRow({ tableId: table._id })) + } + // rows.sort((a, b) => (a._id! > b._id! ? 1 : -1)) + + const createViewResponse = await config.api.viewV2.create() + const allRows = (await config.api.viewV2.search(createViewResponse.id)) + .body.rows + + const firstPageResponse = await config.api.viewV2.search( + createViewResponse.id, + { + paginate: true, + limit: 4, + } + ) + expect(firstPageResponse.body).toEqual({ + rows: expect.arrayContaining(allRows.slice(0, 4)), + totalRows: 10, + hasNextPage: true, + bookmark: expect.any(String), + }) + + const secondPageResponse = await config.api.viewV2.search( + createViewResponse.id, + { + paginate: true, + limit: 4, + bookmark: firstPageResponse.body.bookmark, + } + ) + expect(secondPageResponse.body).toEqual({ + rows: expect.arrayContaining(allRows.slice(4, 8)), + totalRows: 10, + hasNextPage: true, + bookmark: expect.any(String), + }) + + const lastPageResponse = await config.api.viewV2.search( + createViewResponse.id, + { + paginate: true, + limit: 4, + bookmark: secondPageResponse.body.bookmark, + } + ) + expect(lastPageResponse.body).toEqual({ + rows: expect.arrayContaining(allRows.slice(8)), + totalRows: 10, + hasNextPage: false, + bookmark: expect.any(String), + }) + }) + }) }) }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index fc97e657c5..e30bc2c0b1 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -62,7 +62,7 @@ describe("/v2/views", () => { name: generator.name(), tableId: config.table!._id!, primaryDisplay: generator.word(), - query: { allOr: false, equal: { field: "value" } }, + query: [{ operator: "equal", field: "field", value: "value" }], sort: { field: "fieldToSort", order: SortOrder.DESCENDING, @@ -190,7 +190,7 @@ describe("/v2/views", () => { const tableId = config.table!._id! await config.api.viewV2.update({ ...view, - query: { equal: { newField: "thatValue" } }, + query: [{ operator: "equal", field: "newField", value: "thatValue" }], }) expect(await config.api.table.get(tableId)).toEqual({ @@ -198,7 +198,9 @@ describe("/v2/views", () => { views: { [view.name]: { ...view, - query: { equal: { newField: "thatValue" } }, + query: [ + { operator: "equal", field: "newField", value: "thatValue" }, + ], schema: expect.anything(), }, }, @@ -216,7 +218,13 @@ describe("/v2/views", () => { tableId, name: view.name, primaryDisplay: generator.word(), - query: { equal: { [generator.word()]: generator.word() } }, + query: [ + { + operator: "equal", + field: generator.word(), + value: generator.word(), + }, + ], sort: { field: generator.word(), order: SortOrder.DESCENDING, @@ -285,7 +293,7 @@ describe("/v2/views", () => { { ...view, tableId: generator.guid(), - query: { equal: { newField: "thatValue" } }, + query: [{ operator: "equal", field: "newField", value: "thatValue" }], }, { expectStatus: 404 } ) diff --git a/packages/server/src/api/routes/view.ts b/packages/server/src/api/routes/view.ts index 18c58134b4..07e31fc701 100644 --- a/packages/server/src/api/routes/view.ts +++ b/packages/server/src/api/routes/view.ts @@ -34,7 +34,7 @@ router "/api/views/:viewName", paramResource("viewName"), authorized( - permissions.PermissionType.VIEW, + permissions.PermissionType.TABLE, permissions.PermissionLevel.READ ), rowController.fetchView diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index dda14a9187..abea725707 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -1,5 +1,7 @@ import newid from "./newid" import { db as dbCore } from "@budibase/backend-core" +import { DocumentType, VirtualDocumentType } from "@budibase/types" +export { DocumentType, VirtualDocumentType } from "@budibase/types" type Optional = string | null @@ -19,7 +21,6 @@ export const BudibaseInternalDB = { export const SEPARATOR = dbCore.SEPARATOR export const StaticDatabases = dbCore.StaticDatabases -export const DocumentType = dbCore.DocumentType export const APP_PREFIX = dbCore.APP_PREFIX export const APP_DEV_PREFIX = dbCore.APP_DEV_PREFIX export const isDevAppID = dbCore.isDevAppID @@ -284,10 +285,22 @@ export function getMultiIDParams(ids: string[]) { * @returns {string} The new view ID which the view doc can be stored under. */ export function generateViewID(tableId: string) { - return `${tableId}${SEPARATOR}${newid()}` + return `${ + VirtualDocumentType.VIEW + }${SEPARATOR}${tableId}${SEPARATOR}${newid()}` +} + +export function isViewID(viewId: string) { + return viewId?.split(SEPARATOR)[0] === VirtualDocumentType.VIEW } export function extractViewInfoFromID(viewId: string) { + if (!isViewID(viewId)) { + throw new Error("Unable to extract table ID, is not a view ID") + } + const split = viewId.split(SEPARATOR) + split.shift() + viewId = split.join(SEPARATOR) const regex = new RegExp(`^(?.+)${SEPARATOR}([^${SEPARATOR}]+)$`) const res = regex.exec(viewId) return { diff --git a/packages/server/src/middleware/noViewData.ts b/packages/server/src/middleware/noViewData.ts deleted file mode 100644 index 809424b6bf..0000000000 --- a/packages/server/src/middleware/noViewData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Ctx, Row } from "@budibase/types" - -export default async (ctx: Ctx, next: any) => { - if (ctx.request.body._viewId) { - return ctx.throw(400, "Table row endpoints cannot contain view info") - } - - return next() -} diff --git a/packages/server/src/middleware/tests/noViewData.spec.ts b/packages/server/src/middleware/tests/noViewData.spec.ts deleted file mode 100644 index 54b0ca8ff8..0000000000 --- a/packages/server/src/middleware/tests/noViewData.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { generator } from "@budibase/backend-core/tests" -import { BBRequest, FieldType, Row, Table } from "@budibase/types" -import { Next } from "koa" -import * as utils from "../../db/utils" -import noViewDataMiddleware from "../noViewData" - -class TestConfiguration { - next: Next - throw: jest.Mock<(status: number, message: string) => never> - middleware: typeof noViewDataMiddleware - params: Record - request?: Pick, "body"> - - constructor() { - this.next = jest.fn() - this.throw = jest.fn() - this.params = {} - - this.middleware = noViewDataMiddleware - } - - executeMiddleware(ctxRequestBody: Row) { - this.request = { - body: ctxRequestBody, - } - return this.middleware( - { - request: this.request as any, - throw: this.throw as any, - params: this.params, - } as any, - this.next - ) - } - - afterEach() { - jest.clearAllMocks() - } -} - -describe("noViewData middleware", () => { - let config: TestConfiguration - - beforeEach(() => { - config = new TestConfiguration() - }) - - afterEach(() => { - config.afterEach() - }) - - const getRandomData = () => ({ - _id: generator.guid(), - name: generator.name(), - age: generator.age(), - address: generator.address(), - }) - - it("it should pass without view id data", async () => { - const data = getRandomData() - await config.executeMiddleware({ - ...data, - }) - - expect(config.next).toBeCalledTimes(1) - expect(config.throw).not.toBeCalled() - }) - - it("it should throw an error if _viewid is provided", async () => { - const data = getRandomData() - await config.executeMiddleware({ - _viewId: generator.guid(), - ...data, - }) - - expect(config.throw).toBeCalledTimes(1) - expect(config.throw).toBeCalledWith( - 400, - "Table row endpoints cannot contain view info" - ) - expect(config.next).not.toBeCalled() - }) -}) diff --git a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts index 427ac9a608..69d1272df9 100644 --- a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts +++ b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts @@ -117,7 +117,7 @@ describe("trimViewRowInfo middleware", () => { }) expect(config.request?.body).toEqual(data) - expect(config.params.tableId).toEqual(table._id) + expect(config.params.sourceId).toEqual(table._id) expect(config.next).toBeCalledTimes(1) expect(config.throw).not.toBeCalled() @@ -143,32 +143,9 @@ describe("trimViewRowInfo middleware", () => { name: data.name, address: data.address, }) - expect(config.params.tableId).toEqual(table._id) + expect(config.params.sourceId).toEqual(table._id) expect(config.next).toBeCalledTimes(1) expect(config.throw).not.toBeCalled() }) - - it("it should throw an error if no viewid is provided on the body", async () => { - const data = getRandomData() - await config.executeMiddleware(viewId, { - ...data, - }) - - expect(config.throw).toBeCalledTimes(1) - expect(config.throw).toBeCalledWith(400, "_viewId is required") - expect(config.next).not.toBeCalled() - }) - - it("it should throw an error if no viewid is provided on the parameters", async () => { - const data = getRandomData() - await config.executeMiddleware(undefined as any, { - _viewId: viewId, - ...data, - }) - - expect(config.throw).toBeCalledTimes(1) - expect(config.throw).toBeCalledWith(400, "viewId path is required") - expect(config.next).not.toBeCalled() - }) }) diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index 2e3ded27f5..cff9dabd37 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -3,26 +3,35 @@ import * as utils from "../db/utils" import sdk from "../sdk" import { db } from "@budibase/backend-core" import { Next } from "koa" +import { getTableId } from "../api/controllers/row/utils" export default async (ctx: Ctx, next: Next) => { const { body } = ctx.request - const { _viewId: viewId } = body + let { _viewId: viewId } = body + + const possibleViewId = getTableId(ctx) + if (utils.isViewID(possibleViewId)) { + viewId = possibleViewId + } + + // nothing to do, it is not a view (just a table ID) if (!viewId) { - return ctx.throw(400, "_viewId is required") + return next() } - if (!ctx.params.viewId) { - return ctx.throw(400, "viewId path is required") + const { tableId } = utils.extractViewInfoFromID(viewId) + + // don't need to trim delete requests + if (ctx?.method?.toLowerCase() !== "delete") { + const { _viewId, ...trimmedView } = await trimViewFields( + viewId, + tableId, + body + ) + ctx.request.body = trimmedView } - const { tableId } = utils.extractViewInfoFromID(ctx.params.viewId) - const { _viewId, ...trimmedView } = await trimViewFields( - viewId, - tableId, - body - ) - ctx.request.body = trimmedView - ctx.params.tableId = tableId + ctx.params.sourceId = tableId return next() } diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts index a523d828f3..4861f473ea 100644 --- a/packages/server/src/sdk/app/rows/search.ts +++ b/packages/server/src/sdk/app/rows/search.ts @@ -1,23 +1,9 @@ -import { SearchFilters, SortOrder, SortType } from "@budibase/types" +import { SearchFilters, SearchParams } from "@budibase/types" import { isExternalTable } from "../../../integrations/utils" import * as internal from "./search/internal" import * as external from "./search/external" import { Format } from "../../../api/controllers/view/exporters" -export interface SearchParams { - tableId: string - paginate?: boolean - query: SearchFilters - bookmark?: string - limit?: number - sort?: string - sortOrder?: SortOrder - sortType?: SortType - version?: string - disableEscaping?: boolean - fields?: string[] -} - export interface ViewParams { calculation: string group: string diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts index 159f3d84fd..447d1d7d16 100644 --- a/packages/server/src/sdk/app/rows/search/external.ts +++ b/packages/server/src/sdk/app/rows/search/external.ts @@ -6,6 +6,7 @@ import { IncludeRelationship, Row, SearchFilters, + SearchParams, } from "@budibase/types" import * as exporters from "../../../../api/controllers/view/exporters" import sdk from "../../../../sdk" @@ -13,7 +14,7 @@ import { handleRequest } from "../../../../api/controllers/row/external" import { breakExternalTableId } from "../../../../integrations/utils" import { cleanExportRows } from "../utils" import { utils } from "@budibase/shared-core" -import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search" +import { ExportRowsParams, ExportRowsResult } from "../search" import { HTTPError, db } from "@budibase/backend-core" import pick from "lodash/pick" diff --git a/packages/server/src/sdk/app/rows/search/internal.ts b/packages/server/src/sdk/app/rows/search/internal.ts index e7f0aadfd6..dfc5b7dcab 100644 --- a/packages/server/src/sdk/app/rows/search/internal.ts +++ b/packages/server/src/sdk/app/rows/search/internal.ts @@ -12,7 +12,7 @@ import { } from "../../../../db/utils" import { getGlobalUsersFromMetadata } from "../../../../utilities/global" import { outputProcessing } from "../../../../utilities/rowProcessor" -import { Database, Row, Table } from "@budibase/types" +import { Database, Row, Table, SearchParams } from "@budibase/types" import { cleanExportRows } from "../utils" import { Format, @@ -28,7 +28,7 @@ import { getFromMemoryDoc, } from "../../../../api/controllers/view/utils" import sdk from "../../../../sdk" -import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search" +import { ExportRowsParams, ExportRowsResult } from "../search" import pick from "lodash/pick" export async function search(options: SearchParams) { diff --git a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts index b24557fd4f..b3bddfbc97 100644 --- a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts @@ -1,8 +1,15 @@ import { GenericContainer } from "testcontainers" -import { Datasource, FieldType, Row, SourceName, Table } from "@budibase/types" +import { + Datasource, + FieldType, + Row, + SourceName, + Table, + SearchParams, +} from "@budibase/types" + import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" -import { SearchParams } from "../../search" import { search } from "../external" import { expectAnyExternalColsAttributes, diff --git a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts index a58c368cea..b3e98a1149 100644 --- a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts @@ -1,6 +1,5 @@ -import { FieldType, Row, Table } from "@budibase/types" +import { FieldType, Row, Table, SearchParams } from "@budibase/types" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" -import { SearchParams } from "../../search" import { search } from "../internal" import { expectAnyInternalColsAttributes, diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index 7e75f22060..637caa06ee 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,17 +1,14 @@ -import { HTTPError, context } from "@budibase/backend-core" +import { context, HTTPError } from "@budibase/backend-core" import { FieldSchema, TableSchema, View, ViewV2 } from "@budibase/types" import sdk from "../../../sdk" import * as utils from "../../../db/utils" -import merge from "lodash/merge" export async function get(viewId: string): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) const table = await sdk.tables.getTable(tableId) const views = Object.values(table.views!) - const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined - - return view + return views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined } export async function create( diff --git a/packages/server/src/tests/utilities/api/row.ts b/packages/server/src/tests/utilities/api/row.ts index c7c72368f5..c6ef4606d2 100644 --- a/packages/server/src/tests/utilities/api/row.ts +++ b/packages/server/src/tests/utilities/api/row.ts @@ -1,4 +1,4 @@ -import { PatchRowRequest } from "@budibase/types" +import { PatchRowRequest, SaveRowRequest, Row } from "@budibase/types" import TestConfiguration from "../TestConfiguration" import { TestAPI } from "./base" @@ -8,12 +8,12 @@ export class RowAPI extends TestAPI { } get = async ( - tableId: string, + sourceId: string, rowId: string, { expectStatus } = { expectStatus: 200 } ) => { const request = this.request - .get(`/api/${tableId}/rows/${rowId}`) + .get(`/api/${sourceId}/rows/${rowId}`) .set(this.config.defaultHeaders()) .expect(expectStatus) if (expectStatus !== 404) { @@ -22,16 +22,43 @@ export class RowAPI extends TestAPI { return request } + save = async ( + sourceId: string, + row: SaveRowRequest, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const resp = await this.request + .post(`/api/${sourceId}/rows`) + .send(row) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return resp.body as Row + } + patch = async ( - tableId: string, + sourceId: string, row: PatchRowRequest, { expectStatus } = { expectStatus: 200 } ) => { return this.request - .patch(`/api/${tableId}/rows`) + .patch(`/api/${sourceId}/rows`) .send(row) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) .expect(expectStatus) } + + delete = async ( + sourceId: string, + rows: Row[], + { expectStatus } = { expectStatus: 200 } + ) => { + return this.request + .delete(`/api/${sourceId}/rows`) + .send({ rows }) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + } } diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts index 5ad2b2d3d7..1520154641 100644 --- a/packages/server/src/tests/utilities/api/viewV2.ts +++ b/packages/server/src/tests/utilities/api/viewV2.ts @@ -1,13 +1,8 @@ import { CreateViewRequest, - SortOrder, - SortType, UpdateViewRequest, - DeleteRowRequest, - PatchRowRequest, - PatchRowResponse, - Row, ViewV2, + SearchViewRowRequest, } from "@budibase/types" import TestConfiguration from "../TestConfiguration" import { TestAPI } from "./base" @@ -81,75 +76,14 @@ export class ViewV2API extends TestAPI { search = async ( viewId: string, - options?: { - sort: { - column: string - order?: SortOrder - type?: SortType - } - }, + params?: SearchViewRowRequest, { expectStatus } = { expectStatus: 200 } ) => { - const qs: [string, any][] = [] - if (options?.sort.column) { - qs.push(["sort_column", options.sort.column]) - } - if (options?.sort.order) { - qs.push(["sort_order", options.sort.order]) - } - if (options?.sort.type) { - qs.push(["sort_type", options.sort.type]) - } - let url = `/api/v2/views/${viewId}/search` - if (qs.length) { - url += "?" + qs.map(q => q.join("=")).join("&") - } return this.request - .get(url) + .post(`/api/v2/views/${viewId}/search`) + .send(params) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) .expect(expectStatus) } - - row = { - create: async ( - viewId: string, - row: Row, - { expectStatus } = { expectStatus: 200 } - ): Promise => { - const result = await this.request - .post(`/api/v2/views/${viewId}/rows`) - .send(row) - .set(this.config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(expectStatus) - return result.body as Row - }, - update: async ( - viewId: string, - rowId: string, - row: PatchRowRequest, - { expectStatus } = { expectStatus: 200 } - ): Promise => { - const result = await this.request - .patch(`/api/v2/views/${viewId}/rows/${rowId}`) - .send(row) - .set(this.config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(expectStatus) - return result.body as PatchRowResponse - }, - delete: async ( - viewId: string, - body: DeleteRowRequest, - { expectStatus } = { expectStatus: 200 } - ): Promise => { - const result = await this.request - .delete(`/api/v2/views/${viewId}/rows`) - .send(body) - .set(this.config.defaultHeaders()) - .expect(expectStatus) - return result.body - }, - } } diff --git a/packages/server/src/utilities/security.ts b/packages/server/src/utilities/security.ts index 694dff4360..0da7621773 100644 --- a/packages/server/src/utilities/security.ts +++ b/packages/server/src/utilities/security.ts @@ -1,5 +1,5 @@ import { permissions, roles } from "@budibase/backend-core" -import { DocumentType } from "../db/utils" +import { DocumentType, VirtualDocumentType } from "../db/utils" export const CURRENTLY_SUPPORTED_LEVELS: string[] = [ permissions.PermissionLevel.WRITE, @@ -11,9 +11,10 @@ export function getPermissionType(resourceId: string) { const docType = Object.values(DocumentType).filter(docType => resourceId.startsWith(docType) )[0] - switch (docType) { + switch (docType as DocumentType | VirtualDocumentType) { case DocumentType.TABLE: case DocumentType.ROW: + case VirtualDocumentType.VIEW: return permissions.PermissionType.TABLE case DocumentType.AUTOMATION: return permissions.PermissionType.AUTOMATION @@ -22,9 +23,6 @@ export function getPermissionType(resourceId: string) { case DocumentType.QUERY: case DocumentType.DATASOURCE: return permissions.PermissionType.QUERY - default: - // views don't have an ID, will end up here - return permissions.PermissionType.VIEW } } diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index 54e0df92df..8739db1b40 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -1,4 +1,12 @@ -import { Datasource, FieldType, SortDirection, SortType } from "@budibase/types" +import { + Datasource, + FieldType, + SearchFilter, + SearchQuery, + SearchQueryFields, + SortDirection, + SortType, +} from "@budibase/types" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { deepGet } from "./helpers" @@ -73,13 +81,13 @@ export const NoEmptyFilterStrings = [ OperatorOptions.NotEquals.value, OperatorOptions.Contains.value, OperatorOptions.NotContains.value, -] as (keyof QueryFields)[] +] as (keyof SearchQueryFields)[] /** * Removes any fields that contain empty strings that would cause inconsistent * behaviour with how backend tables are filtered (no value means no filter). */ -const cleanupQuery = (query: Query) => { +const cleanupQuery = (query: SearchQuery) => { if (!query) { return query } @@ -110,66 +118,12 @@ const removeKeyNumbering = (key: string) => { } } -type Filter = { - operator: keyof Query - field: string - type: any - value: any - externalType: keyof typeof SqlNumberTypeRangeMap -} - -type Query = QueryFields & QueryConfig -type QueryFields = { - string?: { - [key: string]: string - } - fuzzy?: { - [key: string]: string - } - range?: { - [key: string]: { - high: number | string - low: number | string - } - } - equal?: { - [key: string]: any - } - notEqual?: { - [key: string]: any - } - empty?: { - [key: string]: any - } - notEmpty?: { - [key: string]: any - } - oneOf?: { - [key: string]: any[] - } - contains?: { - [key: string]: any[] - } - notContains?: { - [key: string]: any[] - } - containsAny?: { - [key: string]: any[] - } -} - -type QueryConfig = { - allOr?: boolean -} - -type QueryFieldsType = keyof QueryFields - /** * Builds a lucene JSON query from the filter structure generated in the builder * @param filter the builder filter structure */ -export const buildLuceneQuery = (filter: Filter[]) => { - let query: Query = { +export const buildLuceneQuery = (filter: SearchFilter[]) => { + let query: SearchQuery = { string: {}, fuzzy: {}, range: {}, @@ -227,9 +181,13 @@ export const buildLuceneQuery = (filter: Filter[]) => { } if (operator.startsWith("range") && query.range) { const minint = - SqlNumberTypeRangeMap[externalType]?.min || Number.MIN_SAFE_INTEGER + SqlNumberTypeRangeMap[ + externalType as keyof typeof SqlNumberTypeRangeMap + ]?.min || Number.MIN_SAFE_INTEGER const maxint = - SqlNumberTypeRangeMap[externalType]?.max || Number.MAX_SAFE_INTEGER + SqlNumberTypeRangeMap[ + externalType as keyof typeof SqlNumberTypeRangeMap + ]?.max || Number.MAX_SAFE_INTEGER if (!query.range[field]) { query.range[field] = { low: type === "number" ? minint : "0000-00-00T00:00:00.000Z", @@ -275,7 +233,7 @@ export const buildLuceneQuery = (filter: Filter[]) => { * @param docs the data * @param query the JSON lucene query */ -export const runLuceneQuery = (docs: any[], query?: Query) => { +export const runLuceneQuery = (docs: any[], query?: SearchQuery) => { if (!docs || !Array.isArray(docs)) { return [] } @@ -289,7 +247,7 @@ export const runLuceneQuery = (docs: any[], query?: Query) => { // Iterates over a set of filters and evaluates a fail function against a doc const match = ( - type: QueryFieldsType, + type: keyof SearchQueryFields, failFn: (docValue: any, testValue: any) => boolean ) => (doc: any) => { @@ -456,7 +414,7 @@ export const luceneLimit = (docs: any[], limit: string) => { return docs.slice(0, numLimit) } -export const hasFilters = (query?: Query) => { +export const hasFilters = (query?: SearchQuery) => { if (!query) { return false } diff --git a/packages/types/src/api/web/app/rows.ts b/packages/types/src/api/web/app/rows.ts index fedb8ec146..2b51c7b203 100644 --- a/packages/types/src/api/web/app/rows.ts +++ b/packages/types/src/api/web/app/rows.ts @@ -1,5 +1,8 @@ +import { SearchParams } from "../../../sdk" import { Row } from "../../../documents" +export interface SaveRowRequest extends Row {} + export interface PatchRowRequest extends Row { _id: string _rev: string @@ -8,6 +11,14 @@ export interface PatchRowRequest extends Row { export interface PatchRowResponse extends Row {} -export interface SearchResponse { +export interface SearchRowRequest extends Omit {} + +export interface SearchViewRowRequest + extends Pick< + SearchRowRequest, + "sort" | "sortOrder" | "sortType" | "limit" | "bookmark" | "paginate" + > {} + +export interface SearchRowResponse { rows: any[] } diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts index 0e0527eb7f..cba1e04f9a 100644 --- a/packages/types/src/api/web/index.ts +++ b/packages/types/src/api/web/index.ts @@ -8,3 +8,4 @@ export * from "./system" export * from "./app" export * from "./global" export * from "./pagination" +export * from "./searchFilter" diff --git a/packages/types/src/api/web/searchFilter.ts b/packages/types/src/api/web/searchFilter.ts new file mode 100644 index 0000000000..1b5948e50c --- /dev/null +++ b/packages/types/src/api/web/searchFilter.ts @@ -0,0 +1,51 @@ +import { FieldType } from "../../documents" + +export type SearchFilter = { + operator: keyof SearchQuery + field: string + type?: FieldType + value: any + externalType?: string +} + +export type SearchQuery = { + allOr?: boolean + string?: { + [key: string]: string + } + fuzzy?: { + [key: string]: string + } + range?: { + [key: string]: { + high: number | string + low: number | string + } + } + equal?: { + [key: string]: any + } + notEqual?: { + [key: string]: any + } + empty?: { + [key: string]: any + } + notEmpty?: { + [key: string]: any + } + oneOf?: { + [key: string]: any[] + } + contains?: { + [key: string]: any[] + } + notContains?: { + [key: string]: any[] + } + containsAny?: { + [key: string]: any[] + } +} + +export type SearchQueryFields = Omit diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index 3fe8b4a500..204b995337 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -1,6 +1,5 @@ -import { SortOrder, SortType } from "../../api" -import { SearchFilters } from "../../sdk" -import { TableSchema, UIFieldMetadata } from "./table" +import { SearchFilter, SortOrder, SortType } from "../../api" +import { UIFieldMetadata } from "./table" export interface View { name: string @@ -20,7 +19,7 @@ export interface ViewV2 { name: string primaryDisplay?: string tableId: string - query?: SearchFilters + query?: SearchFilter[] sort?: { field: string order?: SortOrder diff --git a/packages/types/src/documents/document.ts b/packages/types/src/documents/document.ts index 6ba318269b..03e01907b8 100644 --- a/packages/types/src/documents/document.ts +++ b/packages/types/src/documents/document.ts @@ -39,6 +39,12 @@ export enum DocumentType { AUDIT_LOG = "al", } +// these documents don't really exist, they are part of other +// documents or enriched into existence as part of get requests +export enum VirtualDocumentType { + VIEW = "view", +} + export interface Document { _id?: string _rev?: string diff --git a/packages/types/src/sdk/index.ts b/packages/types/src/sdk/index.ts index e4b5778ed9..0eab2ba556 100644 --- a/packages/types/src/sdk/index.ts +++ b/packages/types/src/sdk/index.ts @@ -19,3 +19,4 @@ export * from "./user" export * from "./cli" export * from "./websocket" export * from "./permissions" +export * from "./row" diff --git a/packages/types/src/sdk/permissions.ts b/packages/types/src/sdk/permissions.ts index 9e51e4c7b4..9fe1970e44 100644 --- a/packages/types/src/sdk/permissions.ts +++ b/packages/types/src/sdk/permissions.ts @@ -14,6 +14,5 @@ export enum PermissionType { WEBHOOK = "webhook", BUILDER = "builder", GLOBAL_BUILDER = "globalBuilder", - VIEW = "view", QUERY = "query", } diff --git a/packages/types/src/sdk/row.ts b/packages/types/src/sdk/row.ts new file mode 100644 index 0000000000..7e4b89e0f7 --- /dev/null +++ b/packages/types/src/sdk/row.ts @@ -0,0 +1,16 @@ +import { SortOrder, SortType } from "../api" +import { SearchFilters } from "./search" + +export interface SearchParams { + tableId: string + paginate?: boolean + query: SearchFilters + bookmark?: string + limit?: number + sort?: string + sortOrder?: SortOrder + sortType?: SortType + version?: string + disableEscaping?: boolean + fields?: string[] +} diff --git a/packages/worker/Dockerfile b/packages/worker/Dockerfile index dd98f3b7f8..4230ee86f8 100644 --- a/packages/worker/Dockerfile +++ b/packages/worker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine +FROM node:18-alpine LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="scripts/watchtower-hooks/pre-check.sh" LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="scripts/watchtower-hooks/pre-update.sh" diff --git a/scripts/build.js b/scripts/build.js index b93ae1bbe6..2ca41b5f7d 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -43,7 +43,6 @@ function runBuild(entry, outfile) { TsconfigPathsPlugin({ tsconfig: tsconfigPathPluginContent }), nodeExternalsPlugin(), ], - target: "node14", preserveSymlinks: true, loader: { ".svelte": "copy",