Skip to content

Commit

Permalink
Merge branch 'feature/LF-2988/define-variable' into 'master'
Browse files Browse the repository at this point in the history
Third-party implementation of defineVariable

See merge request lfor/fhirpath.js!10
  • Loading branch information
yuriy-sedinkin committed Apr 16, 2024
2 parents 5c1ab23 + aa8c107 commit 284ca22
Show file tree
Hide file tree
Showing 8 changed files with 420 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
This log documents significant changes for each release. This project follows
[Semantic Versioning](http://semver.org/).

## [3.13.0] - 2024-04-10
### Added
- Function `defineVariable(name: String [, expr: expression])`.

## [3.12.0] - 2024-04-10
### Changed
- Updated Cypress to version 13.
Expand Down
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ declare module "fhirpath" {
): any[];
export function resolveInternalTypes(value: any): any;
export function types(value: any): string[];
export function parse(expression: string): any;
export const version :string;
}

declare module "fhirpath/fhir-context/dstu2" {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fhirpath",
"version": "3.12.0",
"version": "3.13.0",
"description": "A FHIRPath engine",
"main": "src/fhirpath.js",
"dependencies": {
Expand Down
40 changes: 33 additions & 7 deletions src/fhirpath.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ let makeResNode = ResourceNode.makeResNode;
// * arity: is index map with type signature
// if type is in array (like [Boolean]) - this means
// function accepts value of this type or empty value {}
// * nullable - means propagate empty result, i.e. instead
// * nullable: means propagate empty result, i.e. instead
// calling function if one of params is empty return empty

engine.invocationTable = {
Expand Down Expand Up @@ -99,6 +99,7 @@ engine.invocationTable = {
exclude: {fn: combining.exclude, arity: {1: ["AnyAtRoot"]}},
iif: {fn: misc.iifMacro, arity: {2: ["Expr", "Expr"], 3: ["Expr", "Expr", "Expr"]}},
trace: {fn: misc.traceFn, arity: {1: ["String"], 2: ["String", "Expr"]}},
defineVariable: {fn: misc.defineVariable, arity: {1: ["String"], 2: ["String", "Expr"]}},
toInteger: {fn: misc.toInteger},
toDecimal: {fn: misc.toDecimal},
toString: {fn: misc.toString},
Expand Down Expand Up @@ -249,11 +250,15 @@ engine.ExternalConstantTerm = function(ctx, parentData, node) {
var extConstant = node.children[0];
var identifier = extConstant.children[0];
var varName = engine.Identifier(ctx, parentData, identifier)[0];

var value = ctx.vars[varName];
if (!(varName in ctx.vars)) {
throw new Error(
"Attempting to access an undefined environment variable: " + varName
);
if (ctx.definedVars && varName in ctx.definedVars)
value = ctx.definedVars[varName];
else
throw new Error(
"Attempting to access an undefined environment variable: " + varName
);
}
// For convenience, we all variable values to be passed in without their array
// wrapper. However, when evaluating, we need to put the array back in.
Expand Down Expand Up @@ -396,12 +401,26 @@ function makeParam(ctx, parentData, type, param) {
if(type === "Expr"){
return function(data) {
const $this = util.arraify(data);
return engine.doEval({ ...ctx, $this }, $this, param);
let ctxExpr = { ...ctx, $this };
if (ctx.definedVars) {
// Each parameter subexpression needs its own set of defined variables
// (cloned from the parent context). This way, the changes to the variables
// are isolated in the subexpression.
ctxExpr.definedVars = {...ctx.definedVars};
}
return engine.doEval(ctxExpr, $this, param);
};
}
if(type === "AnyAtRoot"){
const $this = ctx.$this || ctx.dataRoot;
return engine.doEval({ ...ctx, $this}, $this, param);
let ctxExpr = { ...ctx, $this};
if (ctx.definedVars) {
// Each parameter subexpression needs its own set of defined variables
// (cloned from the parent context). This way, the changes to the variables
// are isolated in the subexpression.
ctxExpr.definedVars = {...ctx.definedVars};
}
return engine.doEval(ctxExpr, $this, param);
}
if(type === "Identifier"){
if(param.type === "TermExpression") {
Expand All @@ -415,7 +434,14 @@ function makeParam(ctx, parentData, type, param) {
return engine.TypeSpecifier(ctx, parentData, param);
}

let res = engine.doEval(ctx, parentData, param);
let ctxExpr = { ...ctx };
if (ctx.definedVars) {
// Each parameter subexpression needs its own set of defined variables
// (cloned from the parent context). This way, the changes to the variables
// are isolated in the subexpression.
ctxExpr.definedVars = {...ctx.definedVars};
}
let res = engine.doEval(ctxExpr, parentData, param);
if(type === "Any") {
return res;
}
Expand Down
30 changes: 30 additions & 0 deletions src/misc.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,36 @@ engine.traceFn = function (x, label, expr) {
return x;
};

/**
* Defines a variable named name that is accessible in subsequent expressions
* and has the value of expr if present, otherwise the value of the input
* collection.
* @param {Array} x - the input collection on which the function is executed
* @param {string} label - the name of the variable to define
* @param {*} [expr] - an expression to run on the input collection
* @returns the value of the input collection (The function should be transparent
* to the caller)
*/
engine.defineVariable = function (x, label, expr) {
let data = x;
if (expr){
data = expr(x);
}
// Just in time initialization of definedVars
if (!this.definedVars) this.definedVars = {};

if (Object.keys(this.vars).includes(label)) {
throw new Error("Environment Variable %" + label + " already defined");
}

if (Object.keys(this.definedVars).includes(label)) {
throw new Error("Variable %" + label + " already defined");
}

this.definedVars[label] = data;
return x;
};

var intRegex = /^[+-]?\d+$/;
engine.toInteger = function(coll){
if(coll.length !== 1) { return []; }
Expand Down
191 changes: 191 additions & 0 deletions test/defineVariable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
const fhirpath = require("../src/fhirpath");
const r4_model = require("../fhir-context/r4");
const _ = require("lodash");
const input = {
get patientExample() {
// Clone input file contents to avoid one test affecting another
return _.cloneDeep(require("../test/resources/patient-example.json"));
},
get conceptMapExample() {
// Clone input file contents to avoid one test affecting another
return _.cloneDeep(require("../test/resources/conceptmap-example.json"));
},
};

describe("defineVariable", () => {

it("simplest variable", () => {
let expr = "defineVariable('v1', 'value1').select(%v1)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["value1"]);
});

it("simple use of a variable", () => {
let expr = "defineVariable('n1', name.first()).select(%n1.given)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["Peter", "James"]);
});

it("simple use of a variable 2 selects", () => {
let expr = "defineVariable('n1', name.first()).select(%n1.given).first()";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["Peter"]);
});

it("use of a variable in separate contexts", () => {
// this example defines the same variable name in 2 different contexts
// this shouldn't report an issue where the variable is being redefined (as it's not in the same context)
let expr = "defineVariable('n1', name.first()).select(%n1.given) | defineVariable('n1', name.skip(1).first()).select(%n1.given)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["Peter", "James", "Jim"]);
});

it("use of a variable in separate contexts defined in 2 but used in 1", () => {
// this example defines the same variable name in 2 different contexts,
// but only uses it in the second. This ensures that the first context doesn't remain when using it in another context
let expr = "defineVariable('n1', name.first()).where(active.not()) | defineVariable('n1', name.skip(1).first()).select(%n1.given)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["Jim"]);
});

it("use of different variables in different contexts", () => {
let expr = "defineVariable('n1', name.first()).select(id & '-' & %n1.given.join('|')) | defineVariable('n2', name.skip(1).first()).select(%n2.given)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["example-Peter|James", "Jim"]);
});

it("2 vars, one unused", () => {
let expr = "defineVariable('n1', name.first()).active | defineVariable('n2', name.skip(1).first()).select(%n2.given)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual([true, "Jim"]);
});

it("composite variable use", () => {
let expr = "defineVariable('v1', 'value1').select(%v1).trace('data').defineVariable('v2', 'value2').select($this & ':' & %v1 & '-' & %v2) | defineVariable('v3', 'value3').select(%v3)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["value1:value1-value2", "value3"]);
});


it("use of a variable outside context throws error", () => {
// test with a variable that is not in the context that should throw an error
let expr = "defineVariable('n1', name.first()).active | defineVariable('n2', name.skip(1).first()).select(%n1.given)";
expect(() => {
fhirpath.evaluate(input.patientExample, expr, r4_model);
}).toThrowError("Attempting to access an undefined environment variable: n1");
});

it("use undefined variable throws error", () => {
// test with a variable that is not in the context that should throw an error
let expr = "select(%fam.given)";
expect(() => {
fhirpath.evaluate(input.patientExample, expr, r4_model);
}).toThrowError("Attempting to access an undefined environment variable: fam");
});

it("redefining variable throws error", () => {
let expr = "defineVariable('v1').defineVariable('v1').select(%v1)";
expect(() => {
fhirpath.evaluate(input.patientExample, expr, r4_model);
}).toThrowError("Variable %v1 already defined");
});

// Yury's tests
it("defineVariable() could not be the first child", () => {
// test with a variable that is not in the context that should throw an error
let expr = "Patient.name.defineVariable('n1', first()).active | Patient.name.defineVariable('n2', skip(1).first()).select(%n1.given)";
expect(() => {
fhirpath.evaluate(input.patientExample, expr, r4_model);
}).toThrowError("Attempting to access an undefined environment variable: n1");
});

it("sequence of variable definitions tweak", () => {
let expr = "Patient.name.defineVariable('n2', skip(1).first()).defineVariable('res', %n2.given+%n2.given).select(%res)";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["JimJim", "JimJim", "JimJim"]);
});

it("sequence of variable definitions original", () => {
// A variable defined based on another variable
let expr = "Patient.name.defineVariable('n1', first()).exists(%n1) | Patient.name.defineVariable('n2', skip(1).first()).defineVariable('res', %n2.given+%n2.given).select(%res)";
const result = fhirpath.evaluate(input.patientExample, expr, r4_model);
// the duplicate JimJim values are removed due to the | operator
expect(result)
.toStrictEqual([true, "JimJim"]);
});

it("multi-tree vars valid", () => {
let expr = "defineVariable('root', 'r1-').select(defineVariable('v1', 'v1').defineVariable('v2', 'v2').select(%v1 | %v2)).select(%root & $this)";
const result = fhirpath.evaluate(input.patientExample, expr, r4_model);
expect(result)
.toStrictEqual(["r1-v1", "r1-v2"]);
});

it("multi-tree vars exception", () => {
let expr = "defineVariable('root', 'r1-').select(defineVariable('v1', 'v1').defineVariable('v2', 'v2').select(%v1 | %v2)).select(%root & $this & %v1)";
expect(() => {
fhirpath.evaluate(input.patientExample, expr, r4_model);
}).toThrowError("Attempting to access an undefined environment variable: v1");
});

it('defineVariable with compile success', () => {
let expr = "defineVariable('root', 'r1-').select(defineVariable('v1', 'v1').defineVariable('v2', 'v2').select(%v1 | %v2)).select(%root & $this)";
let f = fhirpath.compile(expr, r4_model);
expect(f(input.patientExample))
.toStrictEqual(["r1-v1", "r1-v2"]);
});

it('defineVariable with compile error', () => {
let expr = "defineVariable('root', 'r1-').select(defineVariable('v1', 'v1').defineVariable('v2', 'v2').select(%v1 | %v2)).select(%root & $this & %v1)";
let f = fhirpath.compile(expr, r4_model);
expect(() => { f(input.patientExample); })
.toThrowError("Attempting to access an undefined environment variable: v1");
});

it('defineVariable cant overwrite an environment var', () => {
let expr = "defineVariable('context', 'oops')";
let f = fhirpath.compile(expr, r4_model);
expect(() => { f(input.patientExample); })
.toThrowError("Environment Variable %context already defined");
});

it("realistic example with conceptmap", () => {
let expr = `
group.select(
defineVariable('grp')
.element
.select(
defineVariable('ele')
.target
.select(%grp.source & '|' & %ele.code & ' ' & equivalence &' ' & %grp.target & '|' & code)
)
)
.trace('all')
.isDistinct()
`;
expect(fhirpath.evaluate(input.conceptMapExample, expr, r4_model)
).toStrictEqual([
false
]);
});

it('defineVariable in function parameters (1)', () => {
let expr = "defineVariable(defineVariable('param','ppp').select(%param), defineVariable('param','value').select(%param)).select(%ppp)";
let f = fhirpath.compile(expr, r4_model);
expect(f(input.patientExample))
.toStrictEqual(["value"]);
});

it('defineVariable in function parameters (2)', () => {
let expr = "'aaa'.replace(defineVariable('param', 'aaa').select(%param), defineVariable('param','bbb').select(%param))";
let f = fhirpath.compile(expr, r4_model);
expect(f(input.patientExample))
.toStrictEqual(["bbb"]);
});

it('defineVariable in function parameters (3)', () => {
let expr = "'aaa'.defineVariable('x', 'xxx').union(defineVariable('v', 'bbb').select(%v)).defineVariable('v', 'ccc').union(select(%v))";
expect(fhirpath.evaluate(input.patientExample, expr, r4_model))
.toStrictEqual(["aaa", "bbb", "ccc"]);
});
});
Loading

0 comments on commit 284ca22

Please sign in to comment.