// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import type { ResourceType } from '@medplum/fhirtypes';
import type { Atom, AtomContext } from '../fhirlexer/parse';
import { InfixOperatorAtom, PrefixOperatorAtom } from '../fhirlexer/parse';
import type { TypedValue } from '../types';
import { PropertyType, isResource } from '../types';
import { getTypedPropertyValueWithPath } from '../typeschema/crawler';
import { functions } from './functions';
import {
booleanToTypedValue,
fhirPathArrayEquals,
fhirPathArrayEquivalent,
fhirPathArrayNotEquals,
fhirPathEquals,
fhirPathIs,
fhirPathNot,
isQuantity,
removeDuplicates,
singleton,
toTypedValue,
} from './utils';
export class FhirPathAtom implements Atom {
readonly original: string;
readonly child: Atom;
constructor(original: string, child: Atom) {
this.original = original;
this.child = child;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
try {
if (input.length > 0) {
const result = [];
for (const e of input) {
result.push(this.child.eval({ parent: context, variables: { $this: e } }, [e]));
}
return result.flat();
} else {
return this.child.eval(context, []);
}
} catch (error) {
throw new Error(`FhirPathError on "${this.original}": ${error}`, { cause: error });
}
}
toString(): string {
return this.child.toString();
}
}
export class LiteralAtom implements Atom {
public readonly value: TypedValue;
constructor(value: TypedValue) {
this.value = value;
}
eval(): TypedValue[] {
return [this.value];
}
toString(): string {
const value = this.value.value;
if (typeof value === 'string') {
return `'${value}'`;
}
return value.toString();
}
}
export class SymbolAtom implements Atom {
readonly name: string;
constructor(name: string) {
this.name = name;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
if (this.name === '$this') {
return input;
}
const variableValue = this.getVariable(context);
if (variableValue) {
return [variableValue];
}
if (this.name.startsWith('%')) {
throw new Error(`Undefined variable ${this.name}`);
}
return input.flatMap((e) => this.evalValue(e)).filter((e) => e?.value !== undefined) as TypedValue[];
}
private getVariable(context: AtomContext): TypedValue | undefined {
const value = context.variables[this.name];
if (value !== undefined) {
return value;
}
if (context.parent) {
return this.getVariable(context.parent);
}
return undefined;
}
private evalValue(typedValue: TypedValue): TypedValue[] | TypedValue | undefined {
const input = typedValue.value;
if (!input || typeof input !== 'object') {
return undefined;
}
if (isResource(input, this.name as ResourceType)) {
return typedValue;
}
return getTypedPropertyValueWithPath(typedValue, this.name);
}
toString(): string {
return this.name;
}
}
export class EmptySetAtom implements Atom {
eval(): [] {
return [];
}
toString(): string {
return '{}';
}
}
export class UnaryOperatorAtom extends PrefixOperatorAtom {
readonly impl: (x: TypedValue[]) => TypedValue[];
constructor(operator: string, child: Atom, impl: (x: TypedValue[]) => TypedValue[]) {
super(operator, child);
this.impl = impl;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
return this.impl(this.child.eval(context, input));
}
toString(): string {
return this.operator + this.child.toString();
}
}
export class AsAtom extends InfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('as', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
return functions.ofType(context, this.left.eval(context, input), this.right);
}
}
export abstract class BooleanInfixOperatorAtom extends InfixOperatorAtom {
abstract eval(context: AtomContext, input: TypedValue[]): TypedValue[];
}
export class ArithemticOperatorAtom extends BooleanInfixOperatorAtom {
readonly impl: (x: number, y: number) => number | boolean;
constructor(operator: string, left: Atom, right: Atom, impl: (x: number, y: number) => number | boolean) {
super(operator, left, right);
this.impl = impl;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftEvalResult = this.left.eval(context, input);
if (leftEvalResult.length !== 1) {
return [];
}
const rightEvalResult = this.right.eval(context, input);
if (rightEvalResult.length !== 1) {
return [];
}
const leftValue = leftEvalResult[0].value;
const rightValue = rightEvalResult[0].value;
const leftNumber = isQuantity(leftValue) ? leftValue.value : leftValue;
const rightNumber = isQuantity(rightValue) ? rightValue.value : rightValue;
const result = this.impl(leftNumber, rightNumber);
if (typeof result === 'boolean') {
return booleanToTypedValue(result);
} else if (isQuantity(leftValue)) {
return [{ type: PropertyType.Quantity, value: { ...leftValue, value: result } }];
} else {
return [toTypedValue(result)];
}
}
}
export class ConcatAtom extends InfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('&', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
const result = [...leftValue, ...rightValue];
if (result.length > 0 && result.every((e) => typeof e.value === 'string')) {
return [{ type: PropertyType.string, value: result.map((e) => e.value as string).join('') }];
}
return result;
}
}
export class ContainsAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('contains', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
return booleanToTypedValue(leftValue.some((e) => e.value === rightValue[0].value));
}
}
export class InAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('in', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const left = singleton(this.left.eval(context, input));
const right = this.right.eval(context, input);
if (!left) {
return [];
}
return booleanToTypedValue(right.some((e) => fhirPathEquals(left, e)[0].value));
}
}
export class DotAtom extends InfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('.', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
return this.right.eval(context, this.left.eval(context, input));
}
toString(): string {
return `${this.left.toString()}.${this.right.toString()}`;
}
}
export class UnionAtom extends InfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('|', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftResult = this.left.eval(context, input);
const rightResult = this.right.eval(context, input);
return removeDuplicates([...leftResult, ...rightResult]);
}
}
export class EqualsAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('=', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
return fhirPathArrayEquals(leftValue, rightValue);
}
}
export class NotEqualsAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('!=', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
return fhirPathArrayNotEquals(leftValue, rightValue);
}
}
export class EquivalentAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('~', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
return fhirPathArrayEquivalent(leftValue, rightValue);
}
}
export class NotEquivalentAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('!~', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
const rightValue = this.right.eval(context, input);
return fhirPathNot(fhirPathArrayEquivalent(leftValue, rightValue));
}
}
export class IsAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('is', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const leftValue = this.left.eval(context, input);
if (leftValue.length !== 1) {
return [];
}
const typeName = (this.right as SymbolAtom).name;
return booleanToTypedValue(fhirPathIs(leftValue[0], typeName));
}
}
/**
* 6.5.1. and
* Returns true if both operands evaluate to true,
* false if either operand evaluates to false,
* and the empty collection otherwise.
*/
export class AndAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('and', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const left = singleton(this.left.eval(context, input), 'boolean');
const right = singleton(this.right.eval(context, input), 'boolean');
if (left?.value === true && right?.value === true) {
return booleanToTypedValue(true);
}
if (left?.value === false || right?.value === false) {
return booleanToTypedValue(false);
}
return [];
}
}
/**
* 6.5.2. or
* Returns false if both operands evaluate to false,
* true if either operand evaluates to true,
* and empty (`{ }`) otherwise:
*/
export class OrAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('or', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const left = singleton(this.left.eval(context, input), 'boolean');
const right = singleton(this.right.eval(context, input), 'boolean');
if (left?.value === false && right?.value === false) {
return booleanToTypedValue(false);
} else if (left?.value || right?.value) {
return booleanToTypedValue(true);
} else {
return [];
}
}
}
/**
* 6.5.4. xor
* Returns true if exactly one of the operands evaluates to true,
* false if either both operands evaluate to true or both operands evaluate to false,
* and the empty collection otherwise.
*/
export class XorAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('xor', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const left = singleton(this.left.eval(context, input), 'boolean');
const right = singleton(this.right.eval(context, input), 'boolean');
if (!left || !right) {
return [];
}
return booleanToTypedValue(left.value !== right.value);
}
}
/**
* 6.5.5. implies
* Returns true if left is true and right is true,
* true left is false and right true, false or empty
* true left is empty
*/
export class ImpliesAtom extends BooleanInfixOperatorAtom {
constructor(left: Atom, right: Atom) {
super('implies', left, right);
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const left = singleton(this.left.eval(context, input), 'boolean');
const right = singleton(this.right.eval(context, input), 'boolean');
if (right?.value === true || left?.value === false) {
return booleanToTypedValue(true);
} else if (!left || !right) {
return [];
}
return booleanToTypedValue(false);
}
}
export class FunctionAtom implements Atom {
readonly name: string;
readonly args: Atom[];
constructor(name: string, args: Atom[]) {
this.name = name;
this.args = args;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const impl = functions[this.name];
if (!impl) {
throw new Error('Unrecognized function: ' + this.name);
}
return impl(context, input, ...this.args);
}
toString(): string {
return `${this.name}(${this.args.map((arg) => arg.toString()).join(', ')})`;
}
}
export class IndexerAtom implements Atom {
readonly left: Atom;
readonly expr: Atom;
constructor(left: Atom, expr: Atom) {
this.left = left;
this.expr = expr;
}
eval(context: AtomContext, input: TypedValue[]): TypedValue[] {
const evalResult = this.expr.eval(context, input);
if (evalResult.length !== 1) {
return [];
}
const index = evalResult[0].value;
if (typeof index !== 'number') {
throw new Error(`Invalid indexer expression: should return integer}`);
}
const leftResult = this.left.eval(context, input);
if (!(index in leftResult)) {
return [];
}
return [leftResult[index]];
}
toString(): string {
return `${this.left.toString()}[${this.expr.toString()}]`;
}
}