Skip to content

Commit

Permalink
implement custom query expression for attributes (#166)
Browse files Browse the repository at this point in the history
* implement custom query expression for attributes

* fix regex warnings

* rm unused regexp group

* rm unused regexp group

* rm unused regexp group

* rm unused regexp group

* rm unused regexp group

* 2.6.56
  • Loading branch information
kbarbounakis authored Oct 11, 2024
1 parent 8d93c16 commit 65924d4
Show file tree
Hide file tree
Showing 8 changed files with 789 additions and 18 deletions.
194 changes: 194 additions & 0 deletions data-field-query-resolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
const {Args, DataError} = require('@themost/common');
const {hasOwnProperty} = require('./has-own-property');
const {QueryEntity, QueryExpression, QueryField} = require('@themost/query');
class DataFieldQueryResolver {
/**
* @param {import("./data-model").DataModel} target
*/
constructor(target) {
this.target = target;
}

/**
*
* @param {string} value
* @returns {string}
*/
formatName(value) {
if (/^\$/.test(value)) {
return value.replace(/(\$?(\w+)?)/g, '$2').replace(/\.(\w+)/g, '.$1')
}
return value;
}

nameReplacer(key, value) {
if (typeof value === 'string') {
if (/^\$\w+$/.test(value)) {
const baseModel = this.target.base();
const name = value.replace(/^\$/, '');
let field = null;
let collection = null;
// try to find if field belongs to base model
if (baseModel) {
field = baseModel.getAttribute(name);
collection = baseModel.viewAdapter;
}
if (field == null) {
collection = this.target.sourceAdapter;
field = this.target.getAttribute(name);
}
if (field) {
return {
$name: collection + '.' + name
}
}
throw new DataError('An expression contains an attribute that cannot be found', null, this.target.name, name);
} else { // noinspection RegExpUnnecessaryNonCapturingGroup
if (/^\$\w+\.\w+$/.test(value)) {
return {
$name: value.replace(/^\$/, '')
}
}
}
}
return value;
}

/**
* @param {import("./types").DataField} field
* @returns {{$select?: import("@themost/query").QueryField, $expand?: import("@themost/query").QueryEntity[]}|null}
*/
resolve(field) {
Args.check(field != null, new DataError('E_FIELD','Field may not be null', null, this.target.name));
if (Array.isArray(field.query) === false) {
return {
select: null,
expand: []
};
}
let expand = [];
let select = null;
const self = this;
// get base model
const baseModel = this.target.base();
for (const stage of field.query) {
if (stage.$lookup) {
// get from model
const from = stage.$lookup.from;
const fromModel = this.target.context.model(from);
if (stage.$lookup.pipeline && stage.$lookup.pipeline.length) {
stage.$lookup.pipeline.forEach(function(pipelineStage) {
if (pipelineStage.$match && pipelineStage.$match.$expr) {
const q = new QueryExpression().select('*').from(self.target.sourceAdapter);
// get expression as string
const exprString = JSON.stringify(pipelineStage.$match.$expr, function(key, value) {
if (typeof value === 'string') {
if (/\$\$\w+/.test(value)) {
let localField = /\$\$(\w+)/.exec(value)[1];
let localFieldAttribute = self.target.getAttribute(localField);
if (localFieldAttribute && localFieldAttribute.model === self.target.name) {
return {
$name: self.target.sourceAdapter + '.' + localField
}
}
if (baseModel) {
localFieldAttribute = baseModel.getAttribute(localField);
if (localFieldAttribute) {
return {
$name: baseModel.viewAdapter + '.' + localField
}
}
}
throw new DataError('E_FIELD', 'Data field cannot be found', null, self.target.name, localField);
}
}
return self.nameReplacer(key, value);
});
const joinCollection = new QueryEntity(fromModel.viewAdapter).as(stage.$lookup.as).left();
Object.defineProperty(joinCollection, 'model', {
configurable: true,
enumerable: false,
writable: true,
value: fromModel.name
});
const joinExpression = Object.assign(new QueryExpression(), {
$where: JSON.parse(exprString)
});
q.join(joinCollection).with(joinExpression);
const appendExpand = [].concat(q.$expand);
expand.push.apply(expand, appendExpand);
}
});
} else {
let localField = this.formatName(stage.$lookup.localField);
if (/\./.test(localField) === false) {
// get local field expression
let localFieldAttribute = this.target.getAttribute(localField);
if (localFieldAttribute && localFieldAttribute.model === this.target.name) {
localField = `${this.target.sourceAdapter}.${localField}`;
} else {
// get base model
const baseModel = this.target.base();
if (baseModel) {
localFieldAttribute = baseModel.getAttribute(localField);
if (localFieldAttribute) {
localField = `${baseModel.viewAdapter}.${localField}`;
}
}
}
}
const foreignField = this.formatName(stage.$lookup.foreignField);
const q = new QueryExpression().select('*').from(this.target.sourceAdapter);
Args.check(fromModel != null, new DataError('E_MODEL', 'Data model cannot be found', null, from));
const joinCollection = new QueryEntity(fromModel.viewAdapter).as(stage.$lookup.as).left();
Object.defineProperty(joinCollection, 'model', {
configurable: true,
enumerable: false,
writable: true,
value: fromModel.name
});
q.join(joinCollection).with(
new QueryExpression().where(new QueryField(localField))
.equal(new QueryField(foreignField).from(stage.$lookup.as))
);
const appendExpand = [].concat(q.$expand);
expand.push.apply(expand, appendExpand);
}
}
const name = field.property || field.name;
if (stage.$project) {
Args.check(hasOwnProperty(stage.$project, name), new DataError('E_QUERY', 'Field projection expression is missing.', null, this.target.name, field.name));
const expr = Object.getOwnPropertyDescriptor(stage.$project, name).value;
if (typeof expr === 'string') {
select = new QueryField(this.formatName(expr)).as(name)
} else {
const expr1 = Object.defineProperty({}, name, {
configurable: true,
enumerable: true,
writable: true,
value: expr
});
// Important note: Field references e.g. $customer.email
// are not supported by @themost/query@Formatter
// and should be replaced by name references e.g. { "$name": "customer.email" }
// A workaround is being used here is a regular expression replacer which
// will try to replace "$customer.email" with { "$name": "customer.email" }
// but this operation is definitely a feature request for @themost/query
const finalExpr = JSON.parse(JSON.stringify(expr1, function(key, value) {
return self.nameReplacer(key, value);
}));
select = Object.assign(new QueryField(), finalExpr);
}
}
}
return {
$select: select,
$expand: expand
}
}

}

module.exports = {
DataFieldQueryResolver
}
55 changes: 42 additions & 13 deletions data-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
var async = require('async');
var {sprintf} = require('sprintf-js');
var _ = require('lodash');
var {isEqual} = require('lodash');
var {QueryUtils} = require('@themost/query');
var {QueryField} = require('@themost/query');
var {QueryFieldRef} = require('@themost/query');
var {NotNullError} = require('@themost/common');
var {NotNullError, DataError} = require('@themost/common');
var {UniqueConstraintError} = require('@themost/common');
var {TraceUtils} = require('@themost/common');
var {TextUtils} = require('@themost/common');
var {DataCacheStrategy} = require('./data-cache');
var {DataFieldQueryResolver} = require('./data-field-query-resolver');

/**
* @classdesc Represents an event listener for validating not nullable fields. This listener is automatically registered in all data models.
Expand Down Expand Up @@ -668,14 +670,32 @@ DataModelCreateViewListener.prototype.afterUpgrade = function(event, callback) {
}
// get base model
var baseModel = self.base();
var additionalExpand = [];
// get array of fields
var fields = self.attributes.filter(function(x) {
return (self.name=== x.model) && (!x.many);
}).map(function(x) {
return QueryField.select(x.name).from(adapter);
});
var fields = [];
try {
fields = self.attributes.filter(function(x) {
return (self.name=== x.model) && (!x.many);
}).map(function(x) {
if (x.readonly && x.query) {
// resolve field expression (and additional joins)
var expr = new DataFieldQueryResolver(self).resolve(x);
if (expr) {
// hold additional joins
additionalExpand.push.apply(additionalExpand, expr.$expand);
// and return the resolved query expression for this field
return expr.$select;
}
// throw error
throw new DataError('E_QUERY', 'The given field defines a custom query expression but it cannot be resolved', null, self.name, x.name);
}
return QueryField.select(x.name).from(adapter);
});
} catch (error) {
return callback(error);
}
/**
* @type {QueryExpression}
* @type {import("@themost/query").QueryExpression}
*/
var q = QueryUtils.query(adapter).select(fields);
var baseAdapter = null;
Expand All @@ -691,14 +711,23 @@ DataModelCreateViewListener.prototype.afterUpgrade = function(event, callback) {
baseFields.push(QueryField.select(x.name).from(baseAdapter))
});
}
if (baseFields.length>0)
{
q.$expand = [];
if (baseFields.length > 0) {
var from = new QueryFieldRef(adapter, self.key().name);
var to = new QueryFieldRef(baseAdapter, self.base().key().name);
q.$expand = { $entity: { },$with:[] };
q.$expand.$entity[baseAdapter]=baseFields;
q.$expand.$with.push(from);
q.$expand.$with.push(to);
var addExpand = { $entity: { },$with:[] }
addExpand.$entity[baseAdapter] = baseFields;
addExpand.$with.push(from);
addExpand.$with.push(to);
q.$expand.push(addExpand);
}
for (var expand of additionalExpand) {
var findIndex = q.$expand.findIndex(function(item) {
return isEqual(expand, item);
});
if (findIndex < 0) {
q.$expand.push(expand);
}
}
//execute query
return db.createView(view, q, function(err) {
Expand Down
13 changes: 12 additions & 1 deletion data-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -2286,8 +2286,19 @@ DataModel.prototype.migrate = function(callback)
return callback(null, false);
}
var context = self.context;
//do migration
// do migration
var fields = self.attributes.filter(function(x) {
if (x.insertable === false && x.editable === false && x.model === self.name) {
if (typeof x.query === 'undefined') {
throw new DataError('E_MODEL', 'A non-insertable and non-editable field should have a custom query defined.', null, self.name, x.name);
}
// validate source and view
if (self.sourceAdapter === self.viewAdapter) {
throw new DataError('E_MODEL', 'A data model with the same source and view data object cannot have virtual columns.', null, self.name, x.name);
}
// exclude virtual column
return false;
}
return (self.name === x.model) && (!x.many);
});

Expand Down
51 changes: 50 additions & 1 deletion model-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,58 @@
},
"validator": {
"type": "string",
"description": "A string which represetns the module path that exports a custom validator e.g. ./validators/custom-validator.js"
"description": "A string which represents the module path that exports a custom validator e.g. ./validators/custom-validator.js"
}
}
},
"query": {
"type": "array",
"description": "Defines a custom query expression to be used while selecting field.",
"items": {
"anyOf":[
{
"type": "object",
"$lookup": {
"type": "object",
"properties": {
"from": {
"type": "string"
},
"localField": {
"type": "string"
},
"foreignField": {
"type": "string"
},
"let": {
"type": "object",
"additionalProperties": true
},
"pipeline": {
"type": "object",
"additionalProperties": true
},
"as": {
"type": "string"
}
},
"additionalProperties": true,
"required": [
"from"
],
"description": "A query expression for joining other data models"
}
},
{
"type": "object",
"$project": {
"type": "object",
"additionalProperties": true,
"description": "A query expression for selecting field"
}
}
]
}
}
},
"required": [
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": "@themost/data",
"version": "2.6.55",
"version": "2.6.56",
"description": "MOST Web Framework Codename Blueshift - Data module",
"main": "index.js",
"types": "index.d.ts",
Expand Down
Loading

0 comments on commit 65924d4

Please sign in to comment.