Skip to main content
Glama
search.ts66.4 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { FhirFilterExpression, Filter, IncludeTarget, SearchRequest, SortRule, WithId } from '@medplum/core'; import { AccessPolicyInteraction, badRequest, DEFAULT_MAX_SEARCH_COUNT, DEFAULT_SEARCH_COUNT, deriveIdentifierSearchParameter, evalFhirPathTyped, FhirFilterComparison, FhirFilterConnective, FhirFilterNegation, flatMapFilter, forbidden, formatSearchQuery, getDataType, getReferenceString, getSearchParameter, invalidSearchOperator, isResource, isUUID, OperationOutcomeError, Operator, parseFhirPath, parseFilterParameter, parseParameter, PropertyType, SearchParameterType, serverError, splitN, splitSearchOnComma, subsetResource, toPeriod, toTypedValue, validateResourceType, } from '@medplum/core'; import type { Bundle, BundleEntry, BundleLink, Reference, Resource, ResourceType, SearchParameter, } from '@medplum/fhirtypes'; import { getConfig } from '../config/loader'; import { systemResourceProjectId } from '../constants'; import { DatabaseMode } from '../database'; import { clamp } from './operations/utils/parameters'; import type { Repository } from './repo'; import { getFullUrl } from './response'; import type { ColumnSearchParameterImplementation } from './searchparameter'; import { getSearchParameterImplementation } from './searchparameter'; import type { Expression, Operator as SQL } from './sql'; import { ArraySubquery, Column, ColumnType, Condition, Conjunction, Disjunction, escapeLikeString, Negation, periodToRangeString, SelectQuery, SqlFunction, Union, UnionAllBuilder, } from './sql'; import { addTokenColumnsOrderBy, buildTokenColumnsSearchFilter } from './token-column'; /** * Defines the maximum number of resources returned in a single search result. */ const maxSearchResults = DEFAULT_MAX_SEARCH_COUNT; const canonicalReferenceTypes: string[] = [PropertyType.canonical, PropertyType.uri]; type SearchRequestWithCountAndOffset<T extends Resource = Resource> = SearchRequest<T> & { count: number; offset: number; }; interface Cursor { version: string; nextInstant: string; excludedIds?: string[]; } /** Linking direction for chained search. */ const Direction = { FORWARD: 1, REVERSE: -1, } as const; interface ChainedSearchLink { originType: string; targetType: string; code: string; implementation: ColumnSearchParameterImplementation; direction: (typeof Direction)['FORWARD'] | (typeof Direction)['REVERSE']; } interface ChainedSearchParameter { chain: ChainedSearchLink[]; filter: Filter; } export interface SearchOptions extends Pick<GetBaseSelectQueryOptions, 'maxResourceVersion'> {} export async function searchImpl<T extends Resource>( repo: Repository, searchRequest: SearchRequest<T>, options?: SearchOptions ): Promise<Bundle<WithId<T>>> { validateSearchResourceTypes(repo, searchRequest); applyCountAndOffsetLimits(searchRequest); let entry = undefined; let rowCount = undefined; let nextResource: T | undefined; if (searchRequest.count > 0) { const builder = getSelectQueryForSearch(repo, searchRequest, options); ({ entry, rowCount, nextResource } = await getSearchEntries<T>(repo, searchRequest, builder)); } let total = undefined; if (searchRequest.total === 'accurate' || searchRequest.total === 'estimate') { total = await getCount(repo, searchRequest, rowCount); } return { resourceType: 'Bundle', type: 'searchset', entry, total, link: getSearchLinks(searchRequest, entry, nextResource), }; } export async function searchByReferenceImpl<T extends Resource>( repo: Repository, searchRequest: SearchRequest<T>, referenceField: string, referenceValues: string[] ): Promise<Record<string, WithId<T>[]>> { validateSearchResourceTypes(repo, searchRequest); // Hold on to references to parts of the SelectQuery that need to be modified per reference value const referenceConditions: Condition[] = []; const referenceColumns: Column[] = []; const searchQuery = getSelectQueryForSearch(repo, searchRequest, { addColumns: true, limitModifier: 0, resourceTypeQueryCallback: (resourceType, builder) => { const param = getSearchParameter(resourceType, referenceField); if (param?.type !== 'reference') { throw new OperationOutcomeError( badRequest(`Invalid reference search parameter on ${resourceType}: ${referenceField}`) ); } const impl = getSearchParameterImplementation(resourceType, param); if (impl.searchStrategy !== 'column') { throw new OperationOutcomeError( badRequest(`Invalid reference search parameter on ${resourceType}: ${referenceField}`) ); } const expr = buildReferenceEqualsCondition(builder.effectiveTableName, impl, referenceValues[0]); referenceConditions.push(expr); builder.whereExpr(expr); const column = new Column(undefined, `'${referenceValues[0]}'`, true, 'ref'); referenceColumns.push(column); builder.column(column); }, }); const unionAllBuilder = new UnionAllBuilder(); for (const refValue of referenceValues) { // Update each condition with the current reference value for (const cond of referenceConditions) { cond.parameter = refValue; } // Update each column with the current reference value literal for (const column of referenceColumns) { column.actualColumnName = `'${refValue}'`; } unionAllBuilder.add(searchQuery); } const rows: { content: string; ref: string; }[] = await unionAllBuilder.execute(repo.getDatabaseClient(DatabaseMode.READER)); const results: Record<string, WithId<T>[]> = Object.create(null); for (const ref of referenceValues) { results[ref] = []; } for (const row of rows) { const resource = JSON.parse(row.content) as WithId<T>; removeResourceFields(resource, repo, searchRequest); results[row.ref].push(resource); } return results; } function applyCountAndOffsetLimits<T extends Resource>( searchRequest: SearchRequest ): asserts searchRequest is SearchRequestWithCountAndOffset<T> { if (searchRequest.count === undefined) { searchRequest.count = DEFAULT_SEARCH_COUNT; } else if (searchRequest.count > maxSearchResults) { searchRequest.count = maxSearchResults; } if (searchRequest.offset === undefined) { searchRequest.offset = 0; } else { const maxOffset = getConfig().maxSearchOffset; if (maxOffset !== undefined && searchRequest.offset > maxOffset) { throw new OperationOutcomeError( badRequest(`Search offset exceeds maximum (got ${searchRequest.offset}, max ${maxOffset})`) ); } } } /** * Validates that the resource type(s) are valid and that the user has permission to read them. * @param repo - The user's repository. * @param searchRequest - The incoming search request. */ function validateSearchResourceTypes(repo: Repository, searchRequest: SearchRequest): void { if (searchRequest.types) { for (const resourceType of searchRequest.types) { validateSearchResourceType(repo, resourceType); } } else { validateSearchResourceType(repo, searchRequest.resourceType); } } /** * Validates that the resource type is valid and that the user has permission to read it. * @param repo - The user's repository. * @param resourceType - The resource type to validate. */ function validateSearchResourceType(repo: Repository, resourceType: ResourceType): void { validateResourceType(resourceType); if (resourceType === 'Binary') { throw new OperationOutcomeError(badRequest('Cannot search on Binary resource type')); } if (!repo.supportsInteraction(AccessPolicyInteraction.SEARCH, resourceType)) { throw new OperationOutcomeError(forbidden); } } interface GetSelectQueryForSearchOptions extends GetBaseSelectQueryOptions { /** Number added to `searchRequest.count` when specifying the query LIMIT. Default is 1 */ limitModifier?: number; } export function getSelectQueryForSearch<T extends Resource>( repo: Repository, searchRequest: SearchRequest<T>, options?: GetSelectQueryForSearchOptions ): SelectQuery { applyCountAndOffsetLimits(searchRequest); const builder = getBaseSelectQuery(repo, searchRequest, options); addSortRules(repo, builder, searchRequest); const count = searchRequest.count; builder.limit(count + (options?.limitModifier ?? 1)); // Request one extra to test if there are more results if (searchRequest.offset > 0) { if (searchRequest.cursor) { throw new OperationOutcomeError(badRequest('Cannot use both offset and cursor')); } builder.offset(searchRequest.offset); } else if (searchRequest.cursor) { const cursor = parseCursor(searchRequest.cursor); if (cursor) { builder.orderBy(new Column(searchRequest.resourceType, 'lastUpdated', false)); builder.whereExpr(new Condition(new Column(searchRequest.resourceType, 'lastUpdated'), '>=', cursor.nextInstant)); if (cursor.excludedIds?.length) { builder.whereExpr(new Negation(new Condition('id', 'IN', cursor.excludedIds))); } } } return builder; } /** * Returns the bundle entries for a search request. * @param repo - The repository. * @param searchRequest - The search request. * @param builder - The `SelectQuery` builder that is ready to execute. * @returns The bundle entries for the search result. */ async function getSearchEntries<T extends Resource>( repo: Repository, searchRequest: SearchRequestWithCountAndOffset<T>, builder: SelectQuery ): Promise<{ entry: BundleEntry<WithId<T>>[]; rowCount: number; nextResource?: T }> { const config = getConfig(); const originalLimit = builder.limit_; if (config.fhirSearchMinLimit !== undefined && config.fhirSearchMinLimit > builder.limit_) { builder.limit(config.fhirSearchMinLimit); } const client = repo.getDatabaseClient(DatabaseMode.READER); let rows: any[]; try { if (config.fhirSearchDiscourageSeqScan) { // Despite the name, this doesn't truly remove the possibility of a sequential scan, // just massively inflates the cost of a sequential scan to the planner. await client.query('SET enable_seqscan = off'); } rows = await builder.execute(client); } finally { if (config.fhirSearchDiscourageSeqScan) { await client.query('RESET enable_seqscan'); } } const rowCount = Math.min(rows.length, originalLimit); const resources = []; for (let i = 0; i < rowCount; i++) { const row = rows[i]; if (row.content) { resources.push(JSON.parse(row.content)); } else { // Handle missing content // In the original implementation of deleted resources, the content was not stored in the database. resources.push({ resourceType: searchRequest.resourceType, id: row.id, meta: { lastUpdated: row.lastUpdated?.toISOString() }, } as WithId<T>); } } let nextResource: T | undefined; if (resources.length > searchRequest.count) { nextResource = resources.pop(); } const entries = resources.map( (resource): BundleEntry<WithId<T>> => ({ fullUrl: getFullUrl(resource.resourceType, resource.id), search: { mode: 'match' }, resource, }) ); if (searchRequest.include || searchRequest.revInclude) { await getExtraEntries(repo, searchRequest, resources, entries); } for (const entry of entries) { if (!entry.resource) { continue; } removeResourceFields(entry.resource, repo, searchRequest); } return { entry: entries, rowCount, nextResource, }; } interface GetBaseSelectQueryOptions { /** If `true`, the "id" and "content" columns are selected. Defaults to `true`. */ addColumns?: boolean; /** Callback invoked for each resource type and its `SelectQuery` after all filters are applied. */ resourceTypeQueryCallback?: (resourceType: SearchRequest['resourceType'], builder: SelectQuery) => void; /** The maximum resource version to include in the search. If zero is specified, only resources with a NULL version are included. */ maxResourceVersion?: number; } function getBaseSelectQuery( repo: Repository, searchRequest: SearchRequest, opts?: GetBaseSelectQueryOptions ): SelectQuery { let builder: SelectQuery; if (searchRequest.types) { const queries: SelectQuery[] = []; for (const resourceType of searchRequest.types) { const query = getBaseSelectQueryForResourceType(repo, resourceType, searchRequest, opts); queries.push(query); } builder = new SelectQuery('combined', new Union(...queries)); if (opts?.addColumns ?? true) { builder.raw('*'); } } else { builder = getBaseSelectQueryForResourceType(repo, searchRequest.resourceType, searchRequest, opts); } return builder; } function getBaseSelectQueryForResourceType( repo: Repository, resourceType: ResourceType, searchRequest: SearchRequest, opts?: GetBaseSelectQueryOptions ): SelectQuery { const builder = new SelectQuery(resourceType); const addColumns = opts?.addColumns !== false; const idColumn = new Column(resourceType, 'id'); if (addColumns) { builder .column(idColumn) .column(new Column(resourceType, 'lastUpdated')) .column(new Column(resourceType, 'content')); } if (opts?.maxResourceVersion !== undefined) { builder.whereExpr(new Condition(new Column(resourceType, '__version'), '<=', opts.maxResourceVersion)); } if (!searchRequest.filters?.some((f) => f.code === '_deleted')) { repo.addDeletedFilter(builder); } repo.addSecurityFilters(builder, resourceType); addSearchFilters(repo, builder, resourceType, searchRequest); if (opts?.resourceTypeQueryCallback) { opts.resourceTypeQueryCallback(resourceType, builder); } if (addColumns && builder.joins.length > 0) { builder.distinctOn(idColumn); } return builder; } function removeResourceFields(resource: Resource, repo: Repository, searchRequest: SearchRequest): void { repo.removeHiddenFields(resource); if (searchRequest.fields) { const schema = getDataType(resource.resourceType); subsetResource( resource, schema.mandatoryProperties ? [...schema.mandatoryProperties, ...searchRequest.fields] : searchRequest.fields ); } else if (searchRequest.summary) { const schema = getDataType(resource.resourceType); if (searchRequest.summary === 'data') { subsetResource( resource, Object.keys(resource).filter((k) => k !== 'text') ); } else if (searchRequest.summary === 'text') { subsetResource(resource, schema.mandatoryProperties ? ['text', ...schema.mandatoryProperties] : ['text']); } else if (searchRequest.summary === 'true') { subsetResource(resource, schema.summaryProperties ? [...schema.summaryProperties] : []); } } } /** * Gets the extra search entries for the _include and _revinclude parameters. * @param repo - The FHIR repository. * @param searchRequest - The original search request. * @param resources - The resources returned by the original search. * @param entries - The output bundle entries. */ async function getExtraEntries<T extends Resource>( repo: Repository, searchRequest: SearchRequest<T>, resources: T[], entries: BundleEntry[] ): Promise<void> { let base: Resource[] = resources; let iterateOnly = false; const seen = new Set<string>(resources.map((r) => `${r.resourceType}/${r.id}`)); let depth = 0; while (base.length > 0) { // Circuit breaker / load limit if (depth >= 5 || entries.length > maxSearchResults) { throw new Error(`Search with _(rev)include reached query scope limit: depth=${depth}, results=${entries.length}`); } const includes = flatMapFilter(searchRequest.include, (p) => !iterateOnly || p.modifier === Operator.ITERATE ? getSearchIncludeEntries(repo, p, base) : undefined ); const revincludes = flatMapFilter(searchRequest.revInclude, (p) => !iterateOnly || p.modifier === Operator.ITERATE ? getSearchRevIncludeEntries(repo, p, base) : undefined ); const includedResources = (await Promise.all([...includes, ...revincludes])).flat(); base = []; for (const entry of includedResources) { const resource = entry.resource as Resource; base.push(resource); const ref = `${resource.resourceType}/${resource.id}`; if (!seen.has(ref)) { entries.push(entry); } seen.add(ref); } iterateOnly = true; // Only consider :iterate params on iterations after the first depth++; } } /** * Returns bundle entries for the resources that are included in the search result. * * See documentation on _include: https://hl7.org/fhir/R4/search.html#include * @param repo - The repository. * @param include - The include parameter. * @param resources - The base search result resources. * @returns The bundle entries for the included resources. */ async function getSearchIncludeEntries( repo: Repository, include: IncludeTarget, resources: Resource[] ): Promise<BundleEntry[]> { const { resourceType, searchParam: code } = include; const searchParam = getSearchParameter(resourceType, code); if (!searchParam) { throw new OperationOutcomeError(badRequest(`Invalid include parameter: ${resourceType}:${code}`)); } const fhirPathResult = evalFhirPathTyped(searchParam.expression as string, resources.map(toTypedValue)); const references: Reference[] = []; const canonicalReferences: string[] = []; for (const result of fhirPathResult) { if (result.type === PropertyType.Reference) { references.push(result.value); } else if (canonicalReferenceTypes.includes(result.type)) { canonicalReferences.push(result.value); } } const includedResources = (await repo.readReferences(references)).filter((v) => isResource(v)) as WithId<Resource>[]; if (searchParam.target && canonicalReferences.length > 0) { const canonicalSearches = searchParam.target.map((resourceType) => { const searchRequest = { resourceType: resourceType, filters: [ { code: 'url', operator: Operator.EQUALS, value: canonicalReferences.join(','), }, ], count: DEFAULT_MAX_SEARCH_COUNT, offset: 0, }; const query = getSelectQueryForSearch(repo, searchRequest); return getSearchEntries(repo, searchRequest, query); }); const searchResults = await Promise.all(canonicalSearches); for (const result of searchResults) { for (const entry of result.entry) { includedResources.push(entry.resource as WithId<Resource>); } } } return includedResources.map((resource) => ({ fullUrl: getFullUrl(resource.resourceType, resource.id), search: { mode: 'include' }, resource, })); } /** * Returns bundle entries for the resources that are reverse included in the search result. * * See documentation on _revinclude: https://hl7.org/fhir/R4/search.html#revinclude * @param repo - The repository. * @param revInclude - The revInclude parameter. * @param resources - The base search result resources. * @returns The bundle entries for the reverse included resources. */ async function getSearchRevIncludeEntries( repo: Repository, revInclude: IncludeTarget, resources: Resource[] ): Promise<BundleEntry[]> { const { resourceType, searchParam: code } = revInclude; const searchParam = getSearchParameter(resourceType, code); if (!searchParam) { throw new OperationOutcomeError(badRequest(`Invalid include parameter: ${resourceType}:${code}`)); } const references = getSearchParameterImplementation(resourceType, searchParam).type === SearchParameterType.CANONICAL ? flatMapFilter(resources, (r) => getCanonicalUrl(r)) : resources.map(getReferenceString); const searchRequest = { resourceType: resourceType as ResourceType, filters: [{ code, operator: Operator.EQUALS, value: references.join(',') }], count: DEFAULT_MAX_SEARCH_COUNT, offset: 0, }; const query = getSelectQueryForSearch(repo, searchRequest); const entries = (await getSearchEntries(repo, searchRequest, query)).entry; for (const entry of entries) { entry.search = { mode: 'include' }; } return entries; } /** * Returns the search bundle links for a search request. * At minimum, the 'self' link will be returned. * If "count" does not equal zero, then 'first', 'next', and 'previous' links will be included. * @param searchRequest - The search request. * @param entries - The search bundle entries. * @param nextResource - The next resource in the search results, which fell outside of the current page. * @returns The search bundle links. */ function getSearchLinks( searchRequest: SearchRequestWithCountAndOffset, entries: BundleEntry[] | undefined, nextResource?: Resource ): BundleLink[] { const result: BundleLink[] = [ { relation: 'self', url: getSearchUrl(searchRequest), }, ]; if (searchRequest.count > 0 && entries?.length) { if (canUseCursorLinks(searchRequest)) { // In order to make progress, the lastUpdated timestamp must change from the first entry of the current page // to the first entry of the next page; otherwise we'd make the exact same request in a loop if (entries[0].resource?.meta?.lastUpdated === nextResource?.meta?.lastUpdated) { throw new OperationOutcomeError(serverError(new Error('Cursor fails to make progress'))); } // Exclude resources that would appear on the next page, but have already been given on this one const excludedIds = flatMapFilter(entries, (entry) => entry.resource?.meta?.lastUpdated === nextResource?.meta?.lastUpdated ? entry.resource?.id : undefined ); buildSearchLinksWithCursor(searchRequest, nextResource, excludedIds, result); } else { buildSearchLinksWithOffset(searchRequest, nextResource, result); } } return result; } /** * Returns true if the search request can use cursor links. * Cursor links are more efficient than offset links for large result sets. * A search request can use cursor links if: * 1. Not using offset pagination * 2. Exactly one sort rule using _lastUpdated ascending * 3. It uses a page size that can accommodate resources with the same lastUpdated * @param searchRequest - The candidate search request. * @returns True if the search request can use cursor links. */ function canUseCursorLinks(searchRequest: SearchRequestWithCountAndOffset): boolean { return ( searchRequest.offset === 0 && searchRequest.count >= 20 && searchRequest.sortRules?.length === 1 && searchRequest.sortRules[0].code === '_lastUpdated' && !searchRequest.sortRules[0].descending ); } /** * Builds the "first", "next", and "previous" links for a search request using cursor pagination. * @param searchRequest - The search request. * @param nextResource - The next resource in the search results, which fell outside of the current page. * @param excludedIds - Resource IDs to exclude from the next page because they were already shown on the current one. * @param result - The search bundle links. */ function buildSearchLinksWithCursor( searchRequest: SearchRequestWithCountAndOffset, nextResource: Resource | undefined, excludedIds: string[], result: BundleLink[] ): void { result.push({ relation: 'first', url: getSearchUrl({ ...searchRequest, cursor: undefined, offset: undefined }), }); if (nextResource) { result.push({ relation: 'next', url: getSearchUrl({ ...searchRequest, cursor: formatCursor({ version: '2', nextInstant: nextResource?.meta?.lastUpdated as string, excludedIds, }), offset: undefined, }), }); } } /** * Parses a cursor string into a Cursor object. * @param cursor - The cursor string. * @returns The Cursor object or undefined if the cursor string is invalid. */ function parseCursor(cursor: string): Cursor | undefined { const version = cursor.slice(0, cursor.indexOf('-')); switch (version) { case '1': return parseV1Cursor(cursor); case '2': return parseV2Cursor(cursor); default: return undefined; } } /** * Parses a V1 cursor string of the format {version}-{nextInstant}-{nextId} * NOTE: The nextId field is no longer used. * @param cursor - The cursor string to parse. * @returns The parsed cursor object. */ function parseV1Cursor(cursor: string): Cursor | undefined { const [version, nextInstant, nextId] = splitN(cursor, '-', 3); if (!nextId) { return undefined; } const date = new Date(Number.parseInt(nextInstant, 10)); return { version, nextInstant: date.toISOString() }; } /** * Parses a V2 cursor string of the format {version}-{nextInstant}-{excludedIds} * NOTE: The excludedIds field is optional, and contains comma-separated IDs * @param cursor - The cursor string to parse. * @returns The parsed cursor object. */ function parseV2Cursor(cursor: string): Cursor | undefined { const [version, nextInstant, excludedIds] = splitN(cursor, '-', 3); if (!nextInstant) { return undefined; } const date = new Date(Number.parseInt(nextInstant, 10)); return { version, nextInstant: date.toISOString(), excludedIds: excludedIds?.split(',') }; } /** * Formats a cursor object into a cursor string. * @param cursor - The cursor object. * @returns The cursor string. */ function formatCursor(cursor: Cursor): string { const date = new Date(cursor.nextInstant); let str = `${cursor.version}-${date.getTime()}`; if (cursor.excludedIds?.length) { str += '-' + cursor.excludedIds.join(','); } return str; } /** * Adds the "first", "next", and "previous" links to the result array using offset pagination. * Offset pagination is slow, and should be avoided if possible. * @param searchRequest - The search request. * @param nextResource - The next resource in the search results, which fell outside of the current page. * @param result - The search bundle links. */ function buildSearchLinksWithOffset( searchRequest: SearchRequestWithCountAndOffset, nextResource: Resource | undefined, result: BundleLink[] ): void { const count = searchRequest.count; const offset = searchRequest.offset; result.push({ relation: 'first', url: getSearchUrl({ ...searchRequest, offset: 0 }), }); if (nextResource) { result.push({ relation: 'next', url: getSearchUrl({ ...searchRequest, offset: offset + count }), }); } if (offset > 0) { result.push({ relation: 'previous', url: getSearchUrl({ ...searchRequest, offset: offset - count }), }); } } function getSearchUrl(searchRequest: SearchRequest): string { return `${getConfig().baseUrl}fhir/R4/${searchRequest.resourceType}${formatSearchQuery(searchRequest)}`; } /** * Returns the count for a search request. * This ignores page number and page size. * We always start with an "estimate" count to protect against expensive queries. * If the estimate is less than the "accurateCountThreshold" config setting (default 1,000,000), then we run an accurate count. * @param repo - The repository. * @param searchRequest - The search request. * @param rowCount - The number of matching results if found. * @returns The total number of matching results. */ async function getCount(repo: Repository, searchRequest: SearchRequest, rowCount?: number): Promise<number> { let estimateCount = await getEstimateCount(repo, searchRequest); estimateCount = clampEstimateCount(searchRequest, estimateCount, rowCount); if (estimateCount < getConfig().accurateCountThreshold) { return getAccurateCount(repo, searchRequest); } return estimateCount; } /** * Returns the total number of matching results for a search request. * This ignores page number and page size. * @param repo - The repository. * @param searchRequest - The search request. * @returns The total number of matching results. */ async function getAccurateCount(repo: Repository, searchRequest: SearchRequest): Promise<number> { const builder = getBaseSelectQuery(repo, searchRequest, { addColumns: false }); if (builder.joins.length > 0) { builder.raw(`COUNT (DISTINCT "${searchRequest.resourceType}"."id")::int AS "count"`); } else { builder.raw('COUNT(*)::int AS "count"'); } const rows = await builder.execute(repo.getDatabaseClient(DatabaseMode.READER)); return rows[0].count as number; } /** * Returns the estimated number of matching results for a search request. * This ignores page number and page size. * This uses the estimated row count technique as described here: https://wiki.postgresql.org/wiki/Count_estimate * @param repo - The repository. * @param searchRequest - The search request. * @returns The total number of matching results. */ async function getEstimateCount(repo: Repository, searchRequest: SearchRequest): Promise<number> { const builder = getBaseSelectQuery(repo, searchRequest); builder.explain = true; // See: https://wiki.postgresql.org/wiki/Count_estimate // This parses the query plan to find the estimated number of rows. const rows = await builder.execute(repo.getDatabaseClient(DatabaseMode.READER)); for (const row of rows) { const queryPlan = row['QUERY PLAN']; const match = /rows=(\d+)/.exec(queryPlan); if (match) { return Number.parseInt(match[1], 10); } } return 0; } /** * Returns a "clamped" estimate count based on the actual row count. * @param searchRequest - The search request. * @param estimateCount - The estimated number of matching results. * @param rowCount - The number of matching results if found. Value can be up to one more than the requested count. * @returns The clamped estimate count. */ export function clampEstimateCount(searchRequest: SearchRequest, estimateCount: number, rowCount?: number): number { if (searchRequest.count === 0 || rowCount === undefined) { // If "count only" or rowCount is undefined, then the estimate is the best we can do return estimateCount; } const pageSize = searchRequest.count ?? DEFAULT_SEARCH_COUNT; const startIndex = searchRequest.offset ?? 0; const minCount = rowCount > 0 ? startIndex + rowCount : 0; const maxCount = rowCount <= pageSize ? startIndex + rowCount : Number.MAX_SAFE_INTEGER; return clamp(minCount, estimateCount, maxCount); } /** * Adds all search filters as "WHERE" clauses to the query builder. * @param repo - The repository. * @param selectQuery - The select query builder. * @param resourceType - The type of resources requested. * @param searchRequest - The search request. */ function addSearchFilters( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, searchRequest: SearchRequest ): void { const expr = buildSearchExpression(repo, selectQuery, resourceType, searchRequest); if (expr) { selectQuery.predicate.expressions.push(expr); } } export function isChainedSearchFilter(filter: Filter): boolean { return filter.code.startsWith('_has:') || filter.code.includes('.'); } export function buildSearchExpression( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, searchRequest: SearchRequest ): Expression | undefined { const expressions: Expression[] = []; if (searchRequest.filters) { for (const filter of searchRequest.filters) { let expr: Expression | undefined; if (isChainedSearchFilter(filter)) { const chain = parseChainedParameter(searchRequest.resourceType, filter); expr = buildChainedSearch(repo, selectQuery, searchRequest.resourceType, chain); } else { expr = buildSearchFilterExpression(repo, selectQuery, resourceType, resourceType, filter); } if (expr) { expressions.push(expr); } } } if (expressions.length === 0) { return undefined; } if (expressions.length === 1) { return expressions[0]; } return new Conjunction(expressions); } /** * Builds a single search filter as "WHERE" clause to the query builder. * @param repo - The repository. * @param selectQuery - The select query builder. * @param resourceType - The type of resources requested. * @param table - The resource table. * @param filter - The search filter. * @returns The search query where expression */ function buildSearchFilterExpression( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, table: string, filter: Filter ): Expression { if (typeof filter.value !== 'string') { throw new OperationOutcomeError(badRequest('Search filter value must be a string')); } if (filter.value.includes('\0')) { throw new OperationOutcomeError(badRequest('Search filter value cannot contain null bytes')); } if (filter.code.startsWith('_has:') || filter.code.includes('.')) { const chain = parseChainedParameter(resourceType, filter); return buildChainedSearch(repo, selectQuery, resourceType, chain); } const specialParamExpression = trySpecialSearchParameter(repo, selectQuery, resourceType, table, filter); if (specialParamExpression) { return specialParamExpression; } let param = getSearchParameter(resourceType, filter.code); if (!param?.code) { throw new OperationOutcomeError(badRequest(`Unknown search parameter: ${filter.code}`)); } if (filter.operator === Operator.IDENTIFIER) { param = deriveIdentifierSearchParameter(param); filter = { code: param.code as string, operator: Operator.EQUALS, value: filter.value, }; } const impl = getSearchParameterImplementation(resourceType, param); if (impl.searchStrategy === 'token-column') { return buildTokenColumnsSearchFilter(resourceType, table, param, filter); } else if (impl.searchStrategy === 'lookup-table') { return impl.lookupTable.buildWhere(selectQuery, resourceType, table, param, filter); } return buildNormalSearchFilterExpression(resourceType, table, param, impl, filter); } /** * Builds a search filter expression for a normal search parameter. * * Not any special cases, just a normal search parameter. * @param resourceType - The FHIR resource type. * @param table - The resource table. * @param param - The FHIR search parameter. * @param impl - The search parameter implementation. * @param filter - The search filter. * @returns A SQL "WHERE" clause expression. */ function buildNormalSearchFilterExpression( resourceType: string, table: string, param: SearchParameter, impl: ColumnSearchParameterImplementation, filter: Filter ): Expression { if (filter.operator === Operator.MISSING) { return new Condition(new Column(table, impl.columnName), filter.value === 'true' ? '=' : '!=', null); } else if (filter.operator === Operator.PRESENT) { return new Condition(new Column(table, impl.columnName), filter.value === 'true' ? '!=' : '=', null); } switch (param.type) { case 'string': return buildStringSearchFilter(table, impl, filter.operator, splitSearchOnComma(filter.value)); case 'token': case 'uri': if (impl.type === SearchParameterType.BOOLEAN) { return buildBooleanSearchFilter(table, impl, filter); } else { return buildTokenSearchFilter(table, impl, filter, splitSearchOnComma(filter.value)); } case 'reference': return buildReferenceSearchFilter(table, impl, filter, splitSearchOnComma(filter.value)); case 'date': return buildDateSearchFilter(table, impl, filter); case 'quantity': return buildQuantitySearchFilter(table, impl, filter); default: { const values = splitSearchOnComma(filter.value).map( (v) => new Condition(new Column(undefined, impl.columnName), fhirOperatorToSqlOperator(filter.operator), v) ); const expr = new Disjunction(values); return impl.array ? new ArraySubquery(new Column(undefined, impl.columnName), expr) : expr; } } } /** * Returns true if the search parameter code is a special search parameter. * * See: https://www.hl7.org/fhir/search.html#all * @param repo - The repository. * @param selectQuery - The select query builder. * @param resourceType - The type of resources requested. * @param table - The resource table. * @param filter - The search filter. * @returns True if the search parameter is a special code. */ function trySpecialSearchParameter( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, table: string, filter: Filter ): Expression | undefined { switch (filter.code) { case '_id': return buildIdSearchFilter( table, { columnName: 'id', type: SearchParameterType.UUID, searchStrategy: 'column', parsedExpression: parseFhirPath('id'), }, filter ); case '_lastUpdated': return buildDateSearchFilter( table, { type: SearchParameterType.DATETIME, columnName: 'lastUpdated', searchStrategy: 'column', parsedExpression: parseFhirPath('lastUpdated'), }, filter ); case '_deleted': return buildBooleanSearchFilter( table, { type: SearchParameterType.BOOLEAN, columnName: 'deleted', searchStrategy: 'column', parsedExpression: parseFhirPath('deleted'), }, filter ); case '_project': { if (filter.operator === Operator.MISSING || filter.operator === Operator.PRESENT) { if ( (filter.operator === Operator.MISSING && filter.value === 'true') || (filter.operator === Operator.PRESENT && filter.value !== 'true') ) { // missing return new Condition(new Column(table, 'projectId'), '=', systemResourceProjectId); } else { // present return new Condition(new Column(table, 'projectId'), '!=', systemResourceProjectId); } } return buildIdSearchFilter( table, { columnName: 'projectId', type: SearchParameterType.UUID, array: false, searchStrategy: 'column', parsedExpression: parseFhirPath('projectId'), }, filter ); } case '_compartment': { return buildIdSearchFilter( table, { columnName: 'compartments', type: SearchParameterType.UUID, array: true, searchStrategy: 'column', parsedExpression: parseFhirPath('compartments'), }, filter ); } case '_filter': return buildFilterParameterExpression(repo, selectQuery, resourceType, table, parseFilterParameter(filter.value)); default: return undefined; } } function buildFilterParameterExpression( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, table: string, filterExpression: FhirFilterExpression ): Expression { if (filterExpression instanceof FhirFilterNegation) { return new Negation(buildFilterParameterExpression(repo, selectQuery, resourceType, table, filterExpression.child)); } else if (filterExpression instanceof FhirFilterConnective) { return buildFilterParameterConnective(repo, selectQuery, resourceType, table, filterExpression); } else if (filterExpression instanceof FhirFilterComparison) { return buildFilterParameterComparison(repo, selectQuery, resourceType, table, filterExpression); } else { throw new OperationOutcomeError(badRequest('Unknown filter expression type')); } } function buildFilterParameterConnective( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, table: string, filterConnective: FhirFilterConnective ): Expression { const expressions = [ buildFilterParameterExpression(repo, selectQuery, resourceType, table, filterConnective.left), buildFilterParameterExpression(repo, selectQuery, resourceType, table, filterConnective.right), ]; return filterConnective.keyword === 'and' ? new Conjunction(expressions) : new Disjunction(expressions); } function buildFilterParameterComparison( repo: Repository, selectQuery: SelectQuery, resourceType: ResourceType, table: string, filterComparison: FhirFilterComparison ): Expression { return buildSearchFilterExpression(repo, selectQuery, resourceType, table, { code: filterComparison.path, operator: filterComparison.operator as Operator, value: filterComparison.value, }); } /** * Adds a string search filter as "WHERE" clause to the query builder. * @param table - The table in which to search. * @param impl - The search parameter implementation info. * @param operator - The search operator. * @param values - The string values to search against. * @returns The select query condition. */ function buildStringSearchFilter( table: string, impl: ColumnSearchParameterImplementation, operator: Operator, values: string[] ): Expression { const column = new Column(impl.array ? undefined : table, impl.columnName); const expression = buildStringFilterExpression(column, operator, values); if (impl.array) { return new ArraySubquery(new Column(table, impl.columnName), expression); } return expression; } const prefixMatchOperators: Operator[] = [Operator.EQUALS, Operator.STARTS_WITH]; function buildStringFilterExpression(column: Column, operator: Operator, values: string[]): Expression { const conditions = values.map((v) => { if (operator === Operator.EXACT) { return new Condition(column, '=', v); } else if (operator === Operator.CONTAINS) { return new Condition(column, 'LOWER_LIKE', `%${escapeLikeString(v)}%`); } else if (prefixMatchOperators.includes(operator)) { return new Condition(column, 'LOWER_LIKE', `${escapeLikeString(v)}%`); } else { throw new OperationOutcomeError(badRequest('Unsupported string search operator: ' + operator)); } }); return new Disjunction(conditions); } /** * Adds an ID search filter as "WHERE" clause to the query builder. * @param table - The resource table name or alias. * @param impl - The search parameter implementation info. * @param filter - The search filter. * @returns The select query condition. */ function buildIdSearchFilter(table: string, impl: ColumnSearchParameterImplementation, filter: Filter): Expression { if (filter.operator === Operator.IN || filter.operator === Operator.NOT_IN) { throw new OperationOutcomeError(invalidSearchOperator(filter.operator, filter.code)); } const values = splitSearchOnComma(filter.value); for (let i = 0; i < values.length; i++) { if (values[i].includes('/')) { values[i] = values[i].split('/').pop() as string; } if (!isUUID(values[i])) { values[i] = '00000000-0000-0000-0000-000000000000'; } } return buildCondition(impl, filter, values, new Column(table, impl.columnName)); } /** * Adds a token search filter as "WHERE" clause to the query builder. * @param table - The resource table. * @param impl - The search parameter implementation info. * @param filter - The search filter. * @param values - The string values to search against. * @returns The select query condition. */ function buildTokenSearchFilter( table: string, impl: ColumnSearchParameterImplementation, filter: Filter, values: string[] ): Expression { return buildCondition(impl, filter, values, new Column(table, impl.columnName)); } const allowedBooleanValues = ['true', 'false']; function buildBooleanSearchFilter( table: string, impl: ColumnSearchParameterImplementation, filter: Filter ): Expression { if (filter.operator === Operator.IN || filter.operator === Operator.NOT_IN) { throw new OperationOutcomeError(invalidSearchOperator(filter.operator, filter.code)); } if (!allowedBooleanValues.includes(filter.value)) { throw new OperationOutcomeError(badRequest(`Boolean search value must be 'true' or 'false'`)); } const negated = filter.operator === Operator.NOT_EQUALS || filter.operator === Operator.NOT; return new Condition(new Column(table, impl.columnName), negated ? 'IS_DISTINCT_FROM' : '=', filter.value); } /** * Adds a reference search filter as "WHERE" clause to the query builder. * @param table - The table in which to search. * @param impl - The search parameter implementation info. * @param filter - The search filter. * @param values - The string values to search against or a Column * @returns The select query condition. */ function buildReferenceSearchFilter( table: string, impl: ColumnSearchParameterImplementation, filter: Filter, values: string[] | Column ): Expression { if (filter.operator === Operator.IN || filter.operator === Operator.NOT_IN) { throw new OperationOutcomeError(invalidSearchOperator(filter.operator, filter.code)); } const column = new Column(table, impl.columnName); if (Array.isArray(values)) { values = values.map((v) => !v.includes('/') && (impl.columnName === 'subject' || impl.columnName === 'patient') ? `Patient/${v}` : v ); } let condition: Condition; if (impl.array) { condition = new Condition(column, 'ARRAY_OVERLAPS_AND_IS_NOT_NULL', values, 'TEXT[]'); } else if (values instanceof Column) { condition = new Condition(column, '=', values); } else if (values.length === 1) { condition = new Condition(column, '=', values[0]); } else { condition = new Condition(column, 'IN', values); } return filter.operator === Operator.NOT || filter.operator === Operator.NOT_EQUALS ? new Negation(condition) : condition; } function buildReferenceEqualsCondition( table: string, impl: ColumnSearchParameterImplementation, value: string | Column ): Condition { const column = new Column(table, impl.columnName); let condition: Condition; if (impl.array) { condition = new Condition(column, 'ARRAY_OVERLAPS_AND_IS_NOT_NULL', [value], 'TEXT[]'); } else { condition = new Condition(column, '=', value); } return condition; } /** * From the dateTime regex on {@link https://hl7.org/fhir/R4/datatypes.html#primitive}, but with: * - year and month required * - seconds optional when minutes specified for backwards compatibility, e.g. 1985-11-30T05:05Z * - A space is allowed instead of a T as the date/time separator */ const supportedDateRegex = /^(\d(\d(\d[1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2]\d|3[0-1])([T ]([01]\d|2[0-3])(:[0-5]\d(:([0-5]\d|60))?(\.\d{1,9})?)?)?(Z|[+-]((0\d|1[0-3]):[0-5]\d|14:00)?)?$/; /** * Perform validation on date or dateTime values to ensure compatibility with Postgres timestamp parsing. * Throws a badRequest OperationOutcomeError if the value is invalid. * @param value - The date or dateTime value to validate. */ function validateDateValue(value: string): void { if (!supportedDateRegex.test(value)) { throw new OperationOutcomeError(badRequest(`Invalid date value: ${value}`)); } const dateValue = new Date(value); if (Number.isNaN(dateValue.getTime())) { throw new OperationOutcomeError(badRequest(`Invalid date value: ${value}`)); } } /** * Adds a date or date/time search filter. * @param table - The resource table name. * @param impl - The search parameter implementation info. * @param filter - The search filter. * @returns The select query condition. */ export function buildDateSearchFilter( table: string, impl: ColumnSearchParameterImplementation, filter: Filter ): Expression { if (filter.operator === Operator.IN || filter.operator === Operator.NOT_IN) { throw new OperationOutcomeError(invalidSearchOperator(filter.operator, filter.code)); } validateDateValue(filter.value); if (table === 'MeasureReport' && impl.columnName === 'period') { // Handle special case for "MeasureReport.period" // This is a trial for using "tstzrange" columns for date/time ranges. // Eventually, this special case will go away, and this will become the default behavior for all "date" search parameters. // See Postgres Range Types: https://www.postgresql.org/docs/current/rangetypes.html // See FHIR "date" search: https://hl7.org/fhir/r4/search.html#date const column = new Column(table, 'period_range'); const period = toPeriod(filter.value); if (!period) { throw new OperationOutcomeError(badRequest(`Invalid period value: ${filter.value}`)); } const periodRangeString = periodToRangeString(period); if (!periodRangeString) { throw new OperationOutcomeError(badRequest(`Invalid period value: ${filter.value}`)); } switch (filter.operator) { case Operator.EQUALS: return new Condition(column, 'RANGE_OVERLAPS', periodRangeString); case Operator.NOT: case Operator.NOT_EQUALS: return new Negation(new Condition(column, 'RANGE_OVERLAPS', periodRangeString)); case Operator.LESS_THAN: return new Condition(column, 'RANGE_OVERLAPS', `(,${period.end})`, ColumnType.TSTZRANGE); case Operator.LESS_THAN_OR_EQUALS: return new Condition(column, 'RANGE_OVERLAPS', `(,${period.end}]`, ColumnType.TSTZRANGE); case Operator.GREATER_THAN: return new Condition(column, 'RANGE_OVERLAPS', `(${period.start},)`, ColumnType.TSTZRANGE); case Operator.GREATER_THAN_OR_EQUALS: return new Condition(column, 'RANGE_OVERLAPS', `[${period.start},)`, ColumnType.TSTZRANGE); case Operator.STARTS_AFTER: return new Condition(column, 'RANGE_STRICTLY_RIGHT_OF', periodRangeString, ColumnType.TSTZRANGE); case Operator.ENDS_BEFORE: return new Condition(column, 'RANGE_STRICTLY_LEFT_OF', periodRangeString, ColumnType.TSTZRANGE); default: throw new Error(`Unknown FHIR operator: ${filter.operator}`); } } return new Condition(new Column(table, impl.columnName), fhirOperatorToSqlOperator(filter.operator), filter.value); } /** * Builds a quantity search filter. * @param table - The resource table name. * @param impl - The search parameter implementation info. * @param filter - The search filter. * @returns The select query condition. */ function buildQuantitySearchFilter( table: string, impl: ColumnSearchParameterImplementation, filter: Filter ): Expression { const [number, _system, _code] = splitN(filter.value, '|', 3); if (!number) { throw new OperationOutcomeError(badRequest('Invalid quantity value: ' + filter.value)); } if (filter.operator === Operator.APPROXIMATELY) { // Search for operators within 10% of the value // See: https://hl7.org/fhir/R4/search.html#prefix // The value for the parameter in the resource is approximately the same to the provided value. // Note that the recommended value for the approximation is 10% of the stated value // (or for a date, 10% of the gap between now and the date), but systems may choose other values where appropriate const numberValue = Number.parseFloat(number); return new Conjunction([ new Condition(new Column(table, impl.columnName), '>=', numberValue * 0.9), new Condition(new Column(table, impl.columnName), '<=', numberValue * 1.1), ]); } return new Condition(new Column(table, impl.columnName), fhirOperatorToSqlOperator(filter.operator), number); } /** * Adds all "order by" clauses to the query builder. * @param repo - The repository. * @param builder - The client query builder. * @param searchRequest - The search request. */ function addSortRules(repo: Repository, builder: SelectQuery, searchRequest: SearchRequest): void { searchRequest.sortRules?.forEach((sortRule) => addOrderByClause(repo, builder, searchRequest, sortRule)); } /** * Adds a single "order by" clause to the query builder. * @param repo - The repository. * @param builder - The client query builder. * @param searchRequest - The search request. * @param sortRule - The sort rule. */ function addOrderByClause( repo: Repository, builder: SelectQuery, searchRequest: SearchRequest, sortRule: SortRule ): void { if (sortRule.code === '_id') { builder.orderBy('id', !!sortRule.descending); return; } if (sortRule.code === '_lastUpdated') { builder.orderBy('lastUpdated', !!sortRule.descending); return; } const resourceType = searchRequest.resourceType; const param = getSearchParameter(resourceType, sortRule.code); if (!param?.code) { throw new OperationOutcomeError(badRequest('Unknown search parameter: ' + sortRule.code)); } const impl = getSearchParameterImplementation(resourceType, param); if (impl.searchStrategy === 'token-column') { addTokenColumnsOrderBy(builder, impl, sortRule); } else if (impl.searchStrategy === 'lookup-table') { impl.lookupTable.addOrderBy(builder, impl, resourceType, sortRule); } else { impl satisfies ColumnSearchParameterImplementation; builder.orderBy(impl.columnName, sortRule.descending); } } /** * Converts a FHIR search operator into a SQL operator. * Only works for simple conversions. * For complex conversions, need to build custom SQL. * @param fhirOperator - The FHIR operator. * @returns The equivalent SQL operator. */ function fhirOperatorToSqlOperator(fhirOperator: Operator): keyof typeof SQL { switch (fhirOperator) { case Operator.EQUALS: case Operator.EXACT: return '='; case Operator.NOT: case Operator.NOT_EQUALS: return '!='; case Operator.GREATER_THAN: case Operator.STARTS_AFTER: return '>'; case Operator.GREATER_THAN_OR_EQUALS: return '>='; case Operator.LESS_THAN: case Operator.ENDS_BEFORE: return '<'; case Operator.LESS_THAN_OR_EQUALS: return '<='; default: throw new Error(`Unknown FHIR operator: ${fhirOperator}`); } } function buildCondition( impl: ColumnSearchParameterImplementation, filter: Filter, values: string[], column?: Column | string ): Condition | Negation { column = column ?? impl.columnName; const negated = filter.operator === Operator.NOT_EQUALS || filter.operator === Operator.NOT; if (impl.array) { const condition = new Condition(column, 'ARRAY_OVERLAPS_AND_IS_NOT_NULL', values, impl.type + '[]'); return negated ? new Negation(condition) : condition; } else if (values.length > 1) { const condition = new Condition(column, 'IN', values, impl.type); return negated ? new Negation(condition) : condition; } else { return new Condition(column, negated ? 'IS_DISTINCT_FROM' : '=', values[0], impl.type); } } function buildChainedSearch( repo: Repository, selectQuery: SelectQuery, resourceType: string, param: ChainedSearchParameter ): Expression { if (param.chain.length > 3) { throw new OperationOutcomeError(badRequest('Search chains longer than three links are not currently supported')); } // Special case: single-link chain of the form param._id=<id> can be rewritten as param=ResourceType/<id> // Note that this does slightly change the behavior of the search query: true chained search would require the // reference to point to an existing resource, while the rewritten query just matches the reference string if (param.chain.length === 1 && param.filter?.code === '_id' && param.chain[0].direction === Direction.FORWARD) { const { targetType, code } = param.chain[0]; const targetId = param.filter.value; return buildSearchFilterExpression(repo, selectQuery, resourceType as ResourceType, resourceType, { code, operator: Operator.EQUALS, value: `${targetType}/${targetId}`, }); } return buildChainedSearchUsingReferenceTable(repo, selectQuery, param); } /** * Builds a chained search using reference tables. * This is the preferred technique for chained searches. * However, reference tables were only populated after Medplum version 2.2.0. * Self-hosted servers need to run a full re-index before this technique can be used. * @param repo - The repository. * @param selectQuery - The select query builder. * @param param - The chained search parameter. * @returns The WHERE clause expression for the final chained filter. */ function buildChainedSearchUsingReferenceTable( repo: Repository, selectQuery: SelectQuery, param: ChainedSearchParameter ): Expression { let link = param.chain[0]; let currentTable = nextChainedTable(link); // Set up subquery for EXISTS(), starting on the first link of the chain let innerQuery: SelectQuery; if (link.implementation.type === SearchParameterType.CANONICAL) { innerQuery = new SelectQuery(currentTable).whereExpr( getCanonicalJoinCondition(selectQuery.effectiveTableName, link, currentTable) ); } else { innerQuery = new SelectQuery(currentTable).whereExpr( lookupTableJoinCondition(selectQuery.effectiveTableName, link, currentTable) ); currentTable = linkLiteralReference(innerQuery, currentTable, link); } // Add joins to inner query for all subsequent chain links for (let i = 1; i < param.chain.length; i++) { link = param.chain[i]; if (link.implementation.type === SearchParameterType.CANONICAL) { currentTable = linkCanonicalReference(innerQuery, currentTable, link); } else { const lookupTable = linkReferenceLookupTable(innerQuery, currentTable, link); currentTable = linkLiteralReference(innerQuery, lookupTable, link); } } // Add terminal conditions on final target table, and return EXISTS() over subquery innerQuery .where(new Column(currentTable, 'id'), '!=', null) .whereExpr( buildSearchFilterExpression(repo, innerQuery, link.targetType as ResourceType, currentTable, param.filter) ); return new SqlFunction('EXISTS', [innerQuery]); } /** * Join a query to the next table via canonical reference (i.e. by `url`). * @param selectQuery - The query to which the join will be added. * @param currentTable - The "current" table in the chained search construction. * @param link - The current link of the chained search. * @returns The next table alias. */ function linkCanonicalReference(selectQuery: SelectQuery, currentTable: string, link: ChainedSearchLink): string { const nextTable = selectQuery.getNextJoinAlias(); const join = getCanonicalJoinCondition(currentTable, link, nextTable); selectQuery.join('LEFT JOIN', nextChainedTable(link), nextTable, join); return nextTable; } /** * Join a query to a reference lookup table for chained search. * @param selectQuery - The query to which the join will be added. * @param currentTable - The "current" table in the chained search construction. * @param link - The current link of the chained search. * @returns The next table alias. */ function linkReferenceLookupTable(selectQuery: SelectQuery, currentTable: string, link: ChainedSearchLink): string { const referenceTable = selectQuery.getNextJoinAlias(); selectQuery.join( 'LEFT JOIN', nextChainedTable(link), referenceTable, lookupTableJoinCondition(currentTable, link, referenceTable) ); return referenceTable; } /** * Join a query to the next resource table for chained search. * @param selectQuery - The query to which the join will be added. * @param lookupTable - The "current" table in the chained search construction, assumed to be a reference lookup table. * @param link - The current link of the chained search. * @returns The next table alias. */ function linkLiteralReference(selectQuery: SelectQuery, lookupTable: string, link: ChainedSearchLink): string { const nextColumn = link.direction === Direction.FORWARD ? 'targetId' : 'resourceId'; const nextTable = selectQuery.getNextJoinAlias(); selectQuery.join( 'LEFT JOIN', link.targetType, nextTable, new Condition(new Column(nextTable, 'id'), '=', new Column(lookupTable, nextColumn)) ); return nextTable; } function getCanonicalJoinCondition(currentTable: string, link: ChainedSearchLink, nextTable: string): Expression { let sourceTable: string, targetTable: string, targetType: string; if (link.direction === Direction.FORWARD) { sourceTable = currentTable; targetTable = nextTable; targetType = link.targetType; } else { sourceTable = nextTable; targetTable = currentTable; targetType = link.originType; } if (!getSearchParameter(targetType, 'url')) { throw new OperationOutcomeError( badRequest(`${targetTable} cannot be chained via canonical reference (${sourceTable}:${link.code})`) ); } const eq = link.implementation.array ? 'IN_SUBQUERY' : '='; return new Condition(new Column(targetTable, 'url'), eq, new Column(sourceTable, link.implementation.columnName)); } function nextChainedTable(link: ChainedSearchLink): string { if (link.implementation.type === SearchParameterType.CANONICAL) { return link.targetType; } else if (link.direction === Direction.FORWARD) { return `${link.originType}_References`; } else { return `${link.targetType}_References`; } } /** * Constructs the condition for joining a resource table to a reference lookup table for chained search. * @param currentTable - The "current" table in the chained search construction, assumed to be a resource table. * @param link - The current link of the chained search. * @param nextTable - The reference lookup table next in the chained search. * @returns The expression relating the two tables, which can be used as a JOIN condition or in a WHERE clause. */ function lookupTableJoinCondition(currentTable: string, link: ChainedSearchLink, nextTable: string): Expression { const column = link.direction === Direction.FORWARD ? 'resourceId' : 'targetId'; return new Conjunction([ new Condition(new Column(nextTable, column), '=', new Column(currentTable, 'id')), new Condition(new Column(nextTable, 'code'), '=', link.code), ]); } function parseChainedParameter(resourceType: string, searchFilter: Filter): ChainedSearchParameter { let currentResourceType = resourceType; const parts = splitChainedSearch(searchFilter.code); const chain: ChainedSearchLink[] = []; let filter: Filter | undefined; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part.startsWith('_has')) { const link = parseReverseChainLink(part, currentResourceType); chain.push(link); currentResourceType = link.targetType; } else if (i === parts.length - 1) { const [code, modifier] = splitN(part, ':', 2); if (code === '_filter') { filter = { code: '_filter', operator: Operator.EQUALS, value: searchFilter.value }; } else { const searchParam = getSearchParameter(currentResourceType, code); if (!searchParam) { throw new Error(`Invalid search parameter at end of chain: ${currentResourceType}?${code}`); } filter = parseParameter(searchParam, modifier ?? searchFilter.operator, searchFilter.value); } } else { const link = parseChainLink(part, currentResourceType); chain.push(link); currentResourceType = link.targetType; } } if (!filter) { throw new OperationOutcomeError(badRequest('Unterminated chained search')); } return { chain, filter }; } function parseChainLink(param: string, currentResourceType: string): ChainedSearchLink { const [code, modifier] = splitN(param, ':', 2); const searchParam = getSearchParameter(currentResourceType, code); if (!searchParam) { throw new Error(`Invalid search parameter in chain: ${currentResourceType}?${code}`); } let targetType: string; if (searchParam.target?.length === 1) { targetType = searchParam.target[0]; } else if (searchParam.target?.includes(modifier as ResourceType)) { targetType = modifier; } else { throw new Error(`Unable to identify next resource type for search parameter: ${currentResourceType}?${code}`); } const implementation = getSearchParameterImplementation(currentResourceType, searchParam); if (implementation.searchStrategy !== 'column') { throw new Error(`Invalid search parameter in chain: ${currentResourceType}?${code}`); } return { originType: currentResourceType, targetType, code, implementation, direction: Direction.FORWARD }; } function parseReverseChainLink(param: string, targetResourceType: string): ChainedSearchLink { const [, resourceType, code] = splitN(param, ':', 3); const searchParam = getSearchParameter(resourceType, code); if (!searchParam) { throw new Error(`Invalid search parameter in chain: ${resourceType}?${code}`); } else if (!searchParam.target?.includes(targetResourceType as ResourceType)) { throw new Error( `Invalid reverse chain link: search parameter ${resourceType}?${code} does not refer to ${targetResourceType}` ); } const implementation = getSearchParameterImplementation(resourceType, searchParam); if (implementation.searchStrategy !== 'column') { throw new Error(`Invalid search parameter in chain: ${resourceType}?${code}`); } return { originType: targetResourceType, targetType: resourceType, code, implementation, direction: Direction.REVERSE, }; } function splitChainedSearch(chain: string): string[] { const params: string[] = []; while (chain) { const peek = chain.slice(0, 5); if (peek === '_has:') { const resourceTypeDelim = chain.indexOf(':', 5); const codeDelim = chain.indexOf(':', resourceTypeDelim + 1); if (resourceTypeDelim < 0 || resourceTypeDelim >= codeDelim) { throw new Error('Invalid search chain: ' + chain); } params.push(chain.slice(0, codeDelim)); chain = chain.slice(codeDelim + 1); } else { let nextDot = chain.indexOf('.'); if (nextDot === -1) { nextDot = chain.length; } params.push(chain.slice(0, nextDot)); chain = chain.slice(nextDot + 1); } } return params; } function getCanonicalUrl(resource: Resource): string | undefined { return (resource as Resource & { url?: string }).url; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server