Merge pull request #12051 from Budibase/fix/user-search-api

RelationshipField - load default and handle proper searching with user API
This commit is contained in:
Michael Drury 2023-10-13 13:21:24 +01:00 committed by GitHub
commit ec6a25fe23
16 changed files with 250 additions and 93 deletions

View File

@ -14,13 +14,14 @@ import {
} from "../db"
import {
BulkDocsResponse,
ContextUser,
SearchQuery,
SearchQueryOperators,
SearchUsersRequest,
User,
ContextUser,
} from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context"
import { user as userCache } from "../cache"
import { getGlobalDB } from "../context"
type GetOpts = { cleanup?: boolean }
@ -39,6 +40,31 @@ function removeUserPassword(users: User | User[]) {
return users
}
export const isSupportedUserSearch = (query: SearchQuery) => {
const allowed = [
{ op: SearchQueryOperators.STRING, key: "email" },
{ op: SearchQueryOperators.EQUAL, key: "_id" },
]
for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") {
return false
}
const fields = Object.keys(operation || {})
// this filter doesn't contain options - ignore
if (fields.length === 0) {
continue
}
const allowedOperation = allowed.find(
allow =>
allow.op === key && fields.length === 1 && fields[0] === allow.key
)
if (!allowedOperation) {
return false
}
}
return true
}
export const bulkGetGlobalUsersById = async (
userIds: string[],
opts?: GetOpts
@ -211,8 +237,8 @@ export const searchGlobalUsersByEmail = async (
const PAGE_LIMIT = 8
export const paginatedUsers = async ({
page,
email,
bookmark,
query,
appId,
}: SearchUsersRequest = {}) => {
const db = getGlobalDB()
@ -222,18 +248,20 @@ export const paginatedUsers = async ({
limit: PAGE_LIMIT + 1,
}
// add a startkey if the page was specified (anchor)
if (page) {
opts.startkey = page
if (bookmark) {
opts.startkey = bookmark
}
// property specifies what to use for the page/anchor
let userList: User[],
property = "_id",
getKey
if (appId) {
if (query?.equal?._id) {
userList = [await getById(query.equal._id)]
} else if (appId) {
userList = await searchGlobalUsersByApp(appId, opts)
getKey = (doc: any) => getGlobalUserByAppPage(appId, doc)
} else if (email) {
userList = await searchGlobalUsersByEmail(email, opts)
} else if (query?.string?.email) {
userList = await searchGlobalUsersByEmail(query?.string?.email, opts)
property = "email"
} else {
// no search, query allDocs

View File

@ -123,7 +123,10 @@
prevUserSearch = search
try {
userPageInfo.loading()
await users.search({ userPage, email: search })
await users.search({
bookmark: userPage,
query: { string: { email: search } },
})
userPageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")

View File

@ -31,7 +31,10 @@
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
await users.search({
bookmark: page,
query: { string: { email: search } },
})
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")

View File

@ -105,19 +105,25 @@
}
}
$: fetchRows(searchTerm, primaryDisplay)
$: fetchRows(searchTerm, primaryDisplay, defaultValue)
const fetchRows = (searchTerm, primaryDisplay) => {
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
const allRowsFetched =
$fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length &&
!$fetch.hasNextPage
// Don't request until we have the primary display
if (!allRowsFetched && primaryDisplay) {
fetch.update({
query: { string: { [primaryDisplay]: searchTerm } },
// Don't request until we have the primary display or default value has been fetched
if (allRowsFetched || !primaryDisplay) {
return
}
if (defaultVal && !optionsObj[defaultVal]) {
await fetch.update({
query: { equal: { _id: defaultVal } },
})
}
await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } },
})
}
const flatten = values => {

View File

@ -10,24 +10,28 @@ export const buildUserEndpoints = API => ({
/**
* Gets a list of users in the current tenant.
* @param {string} page The page to retrieve
* @param {string} search The starts with string to search username/email by.
* @param {string} bookmark The page to retrieve
* @param {object} query search filters for lookup by user (all operators not supported).
* @param {string} appId Facilitate app/role based user searching
* @param {boolean} paginated Allow the disabling of pagination
* @param {boolean} paginate Allow the disabling of pagination
* @param {number} limit How many users to retrieve in a single search
*/
searchUsers: async ({ paginated, page, email, appId } = {}) => {
searchUsers: async ({ paginate, bookmark, query, appId, limit } = {}) => {
const opts = {}
if (page) {
opts.page = page
if (bookmark) {
opts.bookmark = bookmark
}
if (email) {
opts.email = email
if (query) {
opts.query = query
}
if (appId) {
opts.appId = appId
}
if (typeof paginated === "boolean") {
opts.paginated = paginated
if (typeof paginate === "boolean") {
opts.paginate = paginate
}
if (limit) {
opts.limit = limit
}
return await API.post({
url: `/api/global/users/search`,

View File

@ -27,7 +27,7 @@
const email = Object.values(searchParams.query.string)[0]
const results = await API.searchUsers({
email,
query: { string: { email } },
})
// Mapping to the expected data within RelationshipCell

View File

@ -1,6 +1,7 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants"
import { LuceneUtils } from "../utils"
export default class UserFetch extends DataFetch {
constructor(opts) {
@ -27,16 +28,25 @@ export default class UserFetch extends DataFetch {
}
async getData() {
const { limit, paginate } = this.options
const { cursor, query } = get(this.store)
let finalQuery
// convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query
if (!LuceneUtils.hasFilters(query) && rest.email) {
finalQuery = { string: { email: rest.email } }
} else {
finalQuery = rest
}
try {
// "query" normally contains a lucene query, but users uses a non-standard
// search endpoint so we use query uniquely here
const res = await this.API.searchUsers({
page: cursor,
email: query.email,
appId: query.appId,
paginated: query.paginated,
})
const opts = {
bookmark: cursor,
query: finalQuery,
appId: appId,
paginate: paginated || paginate,
limit,
}
const res = await this.API.searchUsers(opts)
return {
rows: res?.data || [],
hasNextPage: res?.hasNextPage || false,

View File

@ -16,6 +16,7 @@ import {
RelationshipType,
Row,
SaveTableRequest,
SearchQueryOperators,
SortOrder,
SortType,
StaticQuotaName,
@ -1141,7 +1142,9 @@ describe.each([
)
const createViewResponse = await config.createView({
query: [{ operator: "equal", field: "age", value: 40 }],
query: [
{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 },
],
schema: viewSchema,
})

View File

@ -3,6 +3,7 @@ import {
CreateViewRequest,
FieldSchema,
FieldType,
SearchQueryOperators,
SortOrder,
SortType,
Table,
@ -89,7 +90,13 @@ describe.each([
name: generator.name(),
tableId: table._id!,
primaryDisplay: generator.word(),
query: [{ operator: "equal", field: "field", value: "value" }],
query: [
{
operator: SearchQueryOperators.EQUAL,
field: "field",
value: "value",
},
],
sort: {
field: "fieldToSort",
order: SortOrder.DESCENDING,
@ -184,7 +191,13 @@ describe.each([
const tableId = table._id!
await config.api.viewV2.update({
...view,
query: [{ operator: "equal", field: "newField", value: "thatValue" }],
query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
})
expect((await config.api.table.get(tableId)).views).toEqual({
@ -207,7 +220,7 @@ describe.each([
primaryDisplay: generator.word(),
query: [
{
operator: "equal",
operator: SearchQueryOperators.EQUAL,
field: generator.word(),
value: generator.word(),
},
@ -279,7 +292,13 @@ describe.each([
{
...view,
tableId: generator.guid(),
query: [{ operator: "equal", field: "newField", value: "thatValue" }],
query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
},
{ expectStatus: 404 }
)

View File

@ -1,12 +1,13 @@
import {
Datasource,
FieldSubtype,
FieldType,
SortDirection,
SortType,
SearchFilter,
SearchQuery,
SearchQueryFields,
FieldSubtype,
SearchQueryOperators,
SortDirection,
SortType,
} from "@budibase/types"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import { deepGet } from "./helpers"
@ -273,22 +274,30 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
}
// Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (docValue: string, testValue: string) => {
const stringMatch = match(
SearchQueryOperators.STRING,
(docValue: string, testValue: string) => {
return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
!docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
)
})
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (docValue: string, testValue: string) => {
const fuzzyMatch = match(
SearchQueryOperators.FUZZY,
(docValue: string, testValue: string) => {
return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
!docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
)
})
// Process a range match
const rangeMatch = match(
"range",
SearchQueryOperators.RANGE,
(
docValue: string | number | null,
testValue: { low: number; high: number }
@ -304,7 +313,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an equal match (fails if the value is different)
const equalMatch = match(
"equal",
SearchQueryOperators.EQUAL,
(docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue !== testValue
}
@ -312,24 +321,32 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a not-equal match (fails if the value is the same)
const notEqualMatch = match(
"notEqual",
SearchQueryOperators.NOT_EQUAL,
(docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue === testValue
}
)
// Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (docValue: string | null) => {
const emptyMatch = match(
SearchQueryOperators.EMPTY,
(docValue: string | null) => {
return docValue != null && docValue !== ""
})
}
)
// Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (docValue: string | null) => {
const notEmptyMatch = match(
SearchQueryOperators.NOT_EMPTY,
(docValue: string | null) => {
return docValue == null || docValue === ""
})
}
)
// Process an includes match (fails if the value is not included)
const oneOf = match("oneOf", (docValue: any, testValue: any) => {
const oneOf = match(
SearchQueryOperators.ONE_OF,
(docValue: any, testValue: any) => {
if (typeof testValue === "string") {
testValue = testValue.split(",")
if (typeof docValue === "number") {
@ -337,21 +354,25 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
}
}
return !testValue?.includes(docValue)
})
}
)
const containsAny = match("containsAny", (docValue: any, testValue: any) => {
const containsAny = match(
SearchQueryOperators.CONTAINS_ANY,
(docValue: any, testValue: any) => {
return !docValue?.includes(...testValue)
})
}
)
const contains = match(
"contains",
SearchQueryOperators.CONTAINS,
(docValue: string | any[], testValue: any[]) => {
return !testValue?.every((item: any) => docValue?.includes(item))
}
)
const notContains = match(
"notContains",
SearchQueryOperators.NOT_CONTAINS,
(docValue: string | any[], testValue: any[]) => {
return testValue?.every((item: any) => docValue?.includes(item))
}
@ -433,7 +454,7 @@ export const hasFilters = (query?: SearchQuery) => {
if (skipped.includes(key) || typeof value !== "object") {
continue
}
if (Object.keys(value).length !== 0) {
if (Object.keys(value || {}).length !== 0) {
return true
}
}

View File

@ -10,43 +10,57 @@ export type SearchFilter = {
externalType?: string
}
export enum SearchQueryOperators {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal",
NOT_EQUAL = "notEqual",
EMPTY = "empty",
NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf",
CONTAINS = "contains",
NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny",
}
export type SearchQuery = {
allOr?: boolean
onEmptyFilter?: EmptyFilterOption
string?: {
[SearchQueryOperators.STRING]?: {
[key: string]: string
}
fuzzy?: {
[SearchQueryOperators.FUZZY]?: {
[key: string]: string
}
range?: {
[SearchQueryOperators.RANGE]?: {
[key: string]: {
high: number | string
low: number | string
}
}
equal?: {
[SearchQueryOperators.EQUAL]?: {
[key: string]: any
}
notEqual?: {
[SearchQueryOperators.NOT_EQUAL]?: {
[key: string]: any
}
empty?: {
[SearchQueryOperators.EMPTY]?: {
[key: string]: any
}
notEmpty?: {
[SearchQueryOperators.NOT_EMPTY]?: {
[key: string]: any
}
oneOf?: {
[SearchQueryOperators.ONE_OF]?: {
[key: string]: any[]
}
contains?: {
[SearchQueryOperators.CONTAINS]?: {
[key: string]: any[]
}
notContains?: {
[SearchQueryOperators.NOT_CONTAINS]?: {
[key: string]: any[]
}
containsAny?: {
[SearchQueryOperators.CONTAINS_ANY]?: {
[key: string]: any[]
}
}

View File

@ -1,4 +1,5 @@
import { User } from "../../documents"
import { SearchQuery } from "./searchFilter"
export interface SaveUserResponse {
_id: string
@ -51,10 +52,10 @@ export interface InviteUsersResponse {
}
export interface SearchUsersRequest {
page?: string
email?: string
bookmark?: string
query?: SearchQuery
appId?: string
paginated?: boolean
paginate?: boolean
}
export interface CreateAdminUserRequest {

View File

@ -197,7 +197,12 @@ export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
export const search = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body
if (body.paginated === false) {
// TODO: for now only one supported search key, string.email
if (body?.query && !userSdk.core.isSupportedUserSearch(body.query)) {
ctx.throw(501, "Can only search by string.email or equal._id")
}
if (body.paginate === false) {
await getAppUsers(ctx)
} else {
const paginated = await userSdk.core.paginatedUsers(body)

View File

@ -544,6 +544,36 @@ describe("/api/global/users", () => {
})
})
describe("POST /api/global/users/search", () => {
it("should be able to search by email", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { string: { email: user.email } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0].email).toBe(user.email)
})
it("should be able to search by _id", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { equal: { _id: user._id } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0]._id).toBe(user._id)
})
it("should throw an error when unimplemented options used", async () => {
const user = await config.createUser()
await config.api.users.searchUsers(
{
query: { equal: { firstName: user.firstName } },
},
501
)
})
})
describe("DELETE /api/global/users/:userId", () => {
it("should be able to destroy a basic user", async () => {
const user = await config.createUser()

View File

@ -4,6 +4,7 @@ import {
InviteUsersRequest,
User,
CreateAdminUserRequest,
SearchQuery,
} from "@budibase/types"
import structures from "../structures"
import { generator } from "@budibase/backend-core/tests"
@ -133,6 +134,15 @@ export class UserAPI extends TestAPI {
.expect(status ? status : 200)
}
searchUsers = ({ query }: { query?: SearchQuery }, status = 200) => {
return this.request
.post("/api/global/users/search")
.set(this.config.defaultHeaders())
.send({ query })
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
getUser = (userId: string, opts?: TestAPIOpts) => {
return this.request
.get(`/api/global/users/${userId}`)

View File

@ -21750,7 +21750,7 @@ vlq@^0.2.2:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
vm2@^3.9.19:
vm2@^3.9.19, vm2@^3.9.8:
version "3.9.19"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a"
integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==