Comparing version 0.0.8 to 0.0.9
# Changelog | ||
## 0.0.9 / 2024-02-28 | ||
**New** | ||
- Simplify `Selector` API to use common method naming and configuration. | ||
- Validate projection expression immediately on selector creation. | ||
- Support querying the entire object using empty expression. | ||
**Fixes** | ||
- Improve extracting dependent fields in projection and query expressions. | ||
## 0.0.8 / 2024-02-26 | ||
**New** | ||
- Replace `boolean` with `UpdateResult` after an update operation. | ||
## 0.0.7 / 2023-09-27 | ||
- fix `notifyAll` semantics to behave correctly for conditions. | ||
## 0.0.6 / 2023-09-26 | ||
- Pin peer dependency as `mingo@6.x.x`. | ||
@@ -15,11 +31,15 @@ - Default clone mode to "copy". | ||
## 0.0.5 / 2023-06-26 | ||
- Add `mingo@6.4.2` as peerDependency. | ||
## 0.0.4 / 2023-06-25 | ||
- Upgrade to `mingo@6.4.2` for `Context` support. | ||
## 0.0.3 / 2023-06-09 | ||
- Load only basic mingo operators by default. | ||
## 0.0.2 / 2023-06-08 | ||
- Upgrade to `mingo@6.4.1` for full update operator support. | ||
@@ -29,2 +49,3 @@ - Default to not cloning inputs on update. | ||
## 0.0.1 / 2023-06-04 | ||
- Moved React integration to separate package [reack-adaka](https://www.npmjs.com/package/react-adaka). | ||
@@ -31,0 +52,0 @@ |
@@ -33,3 +33,3 @@ "use strict"; | ||
this.selectors = new Set(); | ||
this.hashIndex = new Map(); | ||
this.cache = new Map(); | ||
// signals for notifying selectors of changes. | ||
@@ -44,2 +44,3 @@ this.signals = new Map(); | ||
* Creates a new observable for a view of the state. | ||
* | ||
* @param projection Fields of the state to view. Expressed as MongoDB projection query. | ||
@@ -50,2 +51,8 @@ * @param condition Conditions to match for a valid state view. Expressed as MongoDB filter query. | ||
select(projection, condition = {}) { | ||
// disallow exclusions. | ||
for (const v of Object.values(projection)) { | ||
// validate projection expression immediately catch errors early. | ||
(0, util_1.assert)(v !== 0 && v !== false, "field exclusion not allowed"); | ||
(0, util_1.assert)((0, util_2.isProjectExpression)(v), `selector projection value must be an object, array, true, or 1: '${JSON.stringify(projection)}'`); | ||
} | ||
// ensure not modifiable. some guards for sanity | ||
@@ -56,16 +63,24 @@ condition = (0, util_2.cloneFrozen)(condition); | ||
const hash = (0, util_1.stringify)({ c: condition, p: projection }); | ||
if (this.hashIndex.has(hash)) { | ||
if (this.cache.has(hash)) { | ||
// anytime we pull selector from cache, we should mark it as dirty. | ||
return this.hashIndex.get(hash); | ||
return this.cache.get(hash); | ||
} | ||
// get expected paths to monitor for changes. use fields in both projection and condition | ||
const [cond, proj] = [condition, projection].map(o => Array.from((0, util_2.extractKeyPaths)(o))); | ||
const expected = Array.from(new Set(cond.concat(proj))); | ||
// get expected paths to monitor for changes. | ||
// extract paths in condition expression | ||
const expected = (0, util_2.getDependentPaths)(Object.entries(condition).reduce((m, [k, v]) => { | ||
m[k] = (0, util_1.normalize)(v); | ||
return m; | ||
}, {}), { includeRootFields: true }); | ||
// extract path in projection expression | ||
(0, util_2.getDependentPaths)(projection, { includeRootFields: false }).forEach(s => expected.add(s)); | ||
// create and add a new selector | ||
const selector = new Selector(this.state, projection, new mingo_1.Query(condition, this.queryOptions), this.queryOptions); | ||
const pred = util_2.sameAncestor.bind(null, expected); | ||
// if no field is specified, select everything. | ||
const pred = !expected.size | ||
? () => true | ||
: util_2.sameAncestor.bind(null, expected); | ||
// function to detect changes and notify observers | ||
const signal = (changed) => { | ||
const isize = new Set(changed.concat(expected)).size; // intersection | ||
const usize = expected.length + changed.length; // union | ||
const isize = new Set(changed.concat(Array.from(expected))).size; // intersection | ||
const usize = expected.size + changed.length; // union | ||
const notify = isize < usize || changed.some(pred); | ||
@@ -79,3 +94,3 @@ // notify listeners only when change is detected | ||
this.signals.set(selector, signal); | ||
this.hashIndex.set(hash, selector); | ||
this.cache.set(hash, selector); | ||
return selector; | ||
@@ -85,3 +100,4 @@ } | ||
* Dispatches an update expression to mutate the state. Triggers a notification to relevant selectors only. | ||
* @param {RawObject} expr Update expression as a MongoDB update query. | ||
* | ||
* @param {UpdateExpression} expr Update expression as a MongoDB update query. | ||
* @param {Array<RawObject>} arrayFilters Array filter expressions to filter elements to update. | ||
@@ -102,7 +118,6 @@ * @param {RawObject} condition Condition to check before applying update. | ||
const signal = this.signals.get(selector); | ||
// take a snapshot of the size before sending the notification. | ||
// this accounts for subscribers that may be removed after notification either from running only once or throwing an error. | ||
const increment = selector.size; | ||
// record the number of listeners before signalling which may remove a listener if it throws or is configured to run once. | ||
const size = selector.size; | ||
if (signal(fields)) { | ||
notifyCount += increment; | ||
notifyCount += size; | ||
} | ||
@@ -176,14 +191,15 @@ } | ||
if (!(0, util_1.isEqual)(prev, val)) { | ||
for (const cb of this.listeners) { | ||
for (const f of this.listeners) { | ||
/*eslint-disable*/ | ||
try { | ||
cb(val); | ||
f(val); | ||
} | ||
catch (_a) { | ||
this.listeners.delete(cb); | ||
// on error unsubscribe listener | ||
this.listeners.delete(f); | ||
} | ||
finally { | ||
if (this.onceOnly.has(cb)) { | ||
this.listeners.delete(cb); | ||
this.onceOnly.delete(cb); | ||
// if runOnce, cleanup afterwards | ||
if (this.onceOnly.delete(f)) { | ||
this.listeners.delete(f); | ||
} | ||
@@ -200,62 +216,43 @@ } | ||
this.listeners.clear(); | ||
this.onceOnly.clear(); | ||
} | ||
/** | ||
* Register a listener to be notified about state updates. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
* Subscribe a listener to be notified about state updates. | ||
* | ||
* @param listener The function to receive new data on update. | ||
* @returns {Unsubscribe} Function to unsubscribe listener. | ||
*/ | ||
listen(listener) { | ||
subscribe(listener, options) { | ||
// check if we are reregistering the same observer | ||
if (this.onceOnly.has(listener)) { | ||
throw new Error(`Already subscribed to listen once.`); | ||
if (this.listeners.has(listener)) { | ||
throw new Error("Listener already subscribed."); | ||
} | ||
if (!this.listeners.has(listener)) { | ||
this.listeners.add(listener); | ||
// setup to throw after first run. | ||
if (options && options.runOnce) { | ||
this.onceOnly.add(listener); | ||
} | ||
return () => { | ||
this.listeners.add(listener); | ||
const unsub = () => { | ||
this.onceOnly.delete(listener); | ||
this.listeners.delete(listener); | ||
}; | ||
} | ||
/** | ||
* Like listen() but also immediately invoke the listener if a value is pending for selector. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
*/ | ||
listenNow(listener) { | ||
// check if we are reregistering the same observer | ||
const unsub = this.listen(listener); | ||
// immediately invoke | ||
const val = this.get(); | ||
if (val !== undefined) { | ||
try { | ||
listener(val); | ||
if (options && options.runImmediately) { | ||
// immediately invoke | ||
const val = this.get(); | ||
if (val !== undefined) { | ||
try { | ||
listener(val); | ||
} | ||
catch (e) { | ||
unsub(); | ||
throw e; | ||
} | ||
finally { | ||
if (this.onceOnly.has(listener)) | ||
unsub(); | ||
} | ||
} | ||
catch (e) { | ||
unsub(); | ||
throw e; | ||
} | ||
} | ||
return unsub; | ||
} | ||
/** | ||
* Like listen(), but invokes the listener only once and then automatically removes it. | ||
* @param listener The observer functino to receive data. | ||
* @returns {Callback} Function to unsubscribe listener explicitly before it is called. | ||
*/ | ||
listenOnce(listener) { | ||
// check if we are reregistering the same observer | ||
if (this.listeners.has(listener) && !this.onceOnly.has(listener)) { | ||
throw new Error(`Already subscribed to listen repeatedly.`); | ||
} | ||
if (!this.onceOnly.has(listener)) { | ||
this.listeners.add(listener); | ||
this.onceOnly.add(listener); | ||
} | ||
return () => { | ||
this.listeners.delete(listener); | ||
this.onceOnly.delete(listener); | ||
}; | ||
} | ||
} | ||
exports.Selector = Selector; |
"use strict"; | ||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
var desc = Object.getOwnPropertyDescriptor(m, k); | ||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { | ||
desc = { enumerable: true, get: function() { return m[k]; } }; | ||
} | ||
Object.defineProperty(o, k2, desc); | ||
}) : (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
o[k2] = m[k]; | ||
})); | ||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { | ||
Object.defineProperty(o, "default", { enumerable: true, value: v }); | ||
}) : function(o, v) { | ||
o["default"] = v; | ||
}); | ||
var __importStar = (this && this.__importStar) || function (mod) { | ||
if (mod && mod.__esModule) return mod; | ||
var result = {}; | ||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); | ||
__setModuleDefault(result, mod); | ||
return result; | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.cloneFrozen = exports.sameAncestor = exports.extractKeyPaths = void 0; | ||
exports.cloneFrozen = exports.sameAncestor = exports.getDependentPaths = exports.isProjectExpression = void 0; | ||
const projectionOperators = __importStar(require("mingo/operators/projection")); | ||
const updateOperators = __importStar(require("mingo/operators/update")); | ||
const util_1 = require("mingo/util"); | ||
@@ -15,7 +40,20 @@ const KEYED_OPERATORS_MAP = Object.freeze({ | ||
}); | ||
const peekOperator = (o) => { | ||
const keys = (0, util_1.isObject)(o) && Object.keys(o); | ||
return keys && keys.length === 1 && (0, util_1.isOperator)(keys[0]) && keys[0]; | ||
}; | ||
/** checks that value is a valid project expression. */ | ||
const isProjectExpression = (o) => o === 1 || | ||
o === true || | ||
!!peekOperator(o) || | ||
((0, util_1.isString)(o) && o.startsWith("$")); | ||
exports.isProjectExpression = isProjectExpression; | ||
/** | ||
* Extract all valid field paths used in the expression. | ||
* Extract all dependent paths used in the expressions for project, match and update expressions. | ||
* | ||
* @param expr The expression. | ||
* @param options Options to customize path retrieval. | ||
*/ | ||
function extractKeyPaths(expr, parent) { | ||
function getDependentPaths(expr, options = { includeRootFields: false }) { | ||
const parent = options.__parent; | ||
const result = new Set(); | ||
@@ -25,8 +63,9 @@ switch ((0, util_1.getType)(expr)) { | ||
// exclude variables which all begin with "$$" | ||
if (expr.startsWith("$$")) | ||
if (expr.startsWith("$$")) { | ||
return result; | ||
} | ||
else if (expr.startsWith("$")) { | ||
result.add(expr.substring(1)); | ||
} | ||
else if (parent) { | ||
else if (parent && !(0, util_1.isOperator)(parent)) { | ||
result.add(parent); | ||
@@ -37,3 +76,3 @@ } | ||
expr | ||
.map(v => extractKeyPaths(v, parent)) | ||
.map(v => getDependentPaths(v, options)) | ||
.forEach(s => s.forEach(v => result.add(v))); | ||
@@ -47,5 +86,6 @@ break; | ||
// handle top-level boolean operators ($and, $or,..) and $expr. | ||
if (key.startsWith("$")) { | ||
if ((0, util_1.isOperator)(key)) { | ||
let val2 = val; | ||
// handle operators with keyed arguments. | ||
// this ensure we process each leaf object correctly and don't treat the leaf itself as a field in our state. | ||
const opts = KEYED_OPERATORS_MAP[key]; | ||
@@ -57,7 +97,44 @@ if (opts && typeof val === "object") { | ||
} | ||
extractKeyPaths(val2, parent).forEach(v => result.add(v)); | ||
getDependentPaths(val2, Object.assign(Object.assign({}, options), { | ||
// for update expressions send the key as the parent, since they are always top-level. | ||
__parent: !parent && (0, util_1.has)(updateOperators, key) ? key : parent })).forEach(v => result.add(v)); | ||
} | ||
else { | ||
const ancestor = parent ? parent + "." + key : key; | ||
extractKeyPaths(val, ancestor).forEach(v => result.add(v)); | ||
// handle update operators first. we pass the operator as the parent since that should always be the top-level field. | ||
// extracting fields for update expressions is not used yet, but may be leveraged for optimizations latter. | ||
if ((0, util_1.isOperator)(parent) && (0, util_1.has)(updateOperators, parent)) { | ||
getDependentPaths(val, Object.assign(Object.assign({}, options), { __parent: key })).forEach(v => result.add(v)); | ||
continue; | ||
} | ||
if (!options.includeRootFields && | ||
!parent && | ||
!(0, exports.isProjectExpression)(val)) { | ||
// skip if not a valid project expression or ignoring root fields. | ||
continue; | ||
} | ||
let ancestor = parent ? parent + "." + key : key; | ||
// Since this method is written to support both $project and $match expressions we need to specially handle projection operators $elemMatch and $slice. | ||
// This avoids treating all top-level fields as valid within the state object which would not be true for projections based on expression operators. | ||
// A user may reuse field names in the state object for their selectors. We want to avoid notifying listeners if the actual dependent state fields have not changed. | ||
// To find out whether we have a projection operator, we peek into the value object to detect and operator and also check the current field is top-level. | ||
// If the operator is not a projection and the field is top-level, we don't record it as a valid state field and pass an empty value further down the extractor. | ||
const op = peekOperator(val); | ||
const valObj = val; | ||
if (op && !options.includeRootFields) { | ||
if ( | ||
// expr is not a projection operator and not nested so the top-level field must be a new alias. | ||
(!parent && !(0, util_1.has)(projectionOperators, op)) || | ||
// $slice has two flavours in MongoDB, so we need to make sure we are looking at the correct one when first condition fails. | ||
// if nested (i.e parent exists), then we know we are using the $slice from the expression operators. | ||
(parent && op === "$slice") || | ||
// if no parent but op is $slice, we need to check the actual type to determine. | ||
// validates for $slice as an expression operator. | ||
(!parent && | ||
op === "$slice" && | ||
(0, util_1.isArray)(valObj["$slice"]) && | ||
!(0, util_1.isNumber)(valObj["$slice"][0]))) { | ||
ancestor = undefined; | ||
} | ||
} | ||
getDependentPaths(val, Object.assign(Object.assign({}, options), { __parent: ancestor })).forEach(v => result.add(v)); | ||
} | ||
@@ -73,3 +150,3 @@ } | ||
} | ||
exports.extractKeyPaths = extractKeyPaths; | ||
exports.getDependentPaths = getDependentPaths; | ||
/** | ||
@@ -76,0 +153,0 @@ * Determines if the selector has a common ancestor with any other in the set. |
@@ -6,4 +6,4 @@ import { Query } from "mingo"; | ||
import { createUpdater } from "mingo/updater"; | ||
import { cloneDeep, isEqual, stringify } from "mingo/util"; | ||
import { cloneFrozen, extractKeyPaths, sameAncestor } from "./util"; | ||
import { assert, cloneDeep, isEqual, normalize, stringify } from "mingo/util"; | ||
import { cloneFrozen, getDependentPaths, isProjectExpression, sameAncestor } from "./util"; | ||
const NONE = Symbol(); | ||
@@ -30,3 +30,3 @@ /** | ||
this.selectors = new Set(); | ||
this.hashIndex = new Map(); | ||
this.cache = new Map(); | ||
// signals for notifying selectors of changes. | ||
@@ -41,2 +41,3 @@ this.signals = new Map(); | ||
* Creates a new observable for a view of the state. | ||
* | ||
* @param projection Fields of the state to view. Expressed as MongoDB projection query. | ||
@@ -47,2 +48,8 @@ * @param condition Conditions to match for a valid state view. Expressed as MongoDB filter query. | ||
select(projection, condition = {}) { | ||
// disallow exclusions. | ||
for (const v of Object.values(projection)) { | ||
// validate projection expression immediately catch errors early. | ||
assert(v !== 0 && v !== false, "field exclusion not allowed"); | ||
assert(isProjectExpression(v), `selector projection value must be an object, array, true, or 1: '${JSON.stringify(projection)}'`); | ||
} | ||
// ensure not modifiable. some guards for sanity | ||
@@ -53,16 +60,24 @@ condition = cloneFrozen(condition); | ||
const hash = stringify({ c: condition, p: projection }); | ||
if (this.hashIndex.has(hash)) { | ||
if (this.cache.has(hash)) { | ||
// anytime we pull selector from cache, we should mark it as dirty. | ||
return this.hashIndex.get(hash); | ||
return this.cache.get(hash); | ||
} | ||
// get expected paths to monitor for changes. use fields in both projection and condition | ||
const [cond, proj] = [condition, projection].map(o => Array.from(extractKeyPaths(o))); | ||
const expected = Array.from(new Set(cond.concat(proj))); | ||
// get expected paths to monitor for changes. | ||
// extract paths in condition expression | ||
const expected = getDependentPaths(Object.entries(condition).reduce((m, [k, v]) => { | ||
m[k] = normalize(v); | ||
return m; | ||
}, {}), { includeRootFields: true }); | ||
// extract path in projection expression | ||
getDependentPaths(projection, { includeRootFields: false }).forEach(s => expected.add(s)); | ||
// create and add a new selector | ||
const selector = new Selector(this.state, projection, new Query(condition, this.queryOptions), this.queryOptions); | ||
const pred = sameAncestor.bind(null, expected); | ||
// if no field is specified, select everything. | ||
const pred = !expected.size | ||
? () => true | ||
: sameAncestor.bind(null, expected); | ||
// function to detect changes and notify observers | ||
const signal = (changed) => { | ||
const isize = new Set(changed.concat(expected)).size; // intersection | ||
const usize = expected.length + changed.length; // union | ||
const isize = new Set(changed.concat(Array.from(expected))).size; // intersection | ||
const usize = expected.size + changed.length; // union | ||
const notify = isize < usize || changed.some(pred); | ||
@@ -76,3 +91,3 @@ // notify listeners only when change is detected | ||
this.signals.set(selector, signal); | ||
this.hashIndex.set(hash, selector); | ||
this.cache.set(hash, selector); | ||
return selector; | ||
@@ -82,3 +97,4 @@ } | ||
* Dispatches an update expression to mutate the state. Triggers a notification to relevant selectors only. | ||
* @param {RawObject} expr Update expression as a MongoDB update query. | ||
* | ||
* @param {UpdateExpression} expr Update expression as a MongoDB update query. | ||
* @param {Array<RawObject>} arrayFilters Array filter expressions to filter elements to update. | ||
@@ -99,7 +115,6 @@ * @param {RawObject} condition Condition to check before applying update. | ||
const signal = this.signals.get(selector); | ||
// take a snapshot of the size before sending the notification. | ||
// this accounts for subscribers that may be removed after notification either from running only once or throwing an error. | ||
const increment = selector.size; | ||
// record the number of listeners before signalling which may remove a listener if it throws or is configured to run once. | ||
const size = selector.size; | ||
if (signal(fields)) { | ||
notifyCount += increment; | ||
notifyCount += size; | ||
} | ||
@@ -172,14 +187,15 @@ } | ||
if (!isEqual(prev, val)) { | ||
for (const cb of this.listeners) { | ||
for (const f of this.listeners) { | ||
/*eslint-disable*/ | ||
try { | ||
cb(val); | ||
f(val); | ||
} | ||
catch (_a) { | ||
this.listeners.delete(cb); | ||
// on error unsubscribe listener | ||
this.listeners.delete(f); | ||
} | ||
finally { | ||
if (this.onceOnly.has(cb)) { | ||
this.listeners.delete(cb); | ||
this.onceOnly.delete(cb); | ||
// if runOnce, cleanup afterwards | ||
if (this.onceOnly.delete(f)) { | ||
this.listeners.delete(f); | ||
} | ||
@@ -196,61 +212,42 @@ } | ||
this.listeners.clear(); | ||
this.onceOnly.clear(); | ||
} | ||
/** | ||
* Register a listener to be notified about state updates. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
* Subscribe a listener to be notified about state updates. | ||
* | ||
* @param listener The function to receive new data on update. | ||
* @returns {Unsubscribe} Function to unsubscribe listener. | ||
*/ | ||
listen(listener) { | ||
subscribe(listener, options) { | ||
// check if we are reregistering the same observer | ||
if (this.onceOnly.has(listener)) { | ||
throw new Error(`Already subscribed to listen once.`); | ||
if (this.listeners.has(listener)) { | ||
throw new Error("Listener already subscribed."); | ||
} | ||
if (!this.listeners.has(listener)) { | ||
this.listeners.add(listener); | ||
// setup to throw after first run. | ||
if (options && options.runOnce) { | ||
this.onceOnly.add(listener); | ||
} | ||
return () => { | ||
this.listeners.add(listener); | ||
const unsub = () => { | ||
this.onceOnly.delete(listener); | ||
this.listeners.delete(listener); | ||
}; | ||
} | ||
/** | ||
* Like listen() but also immediately invoke the listener if a value is pending for selector. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
*/ | ||
listenNow(listener) { | ||
// check if we are reregistering the same observer | ||
const unsub = this.listen(listener); | ||
// immediately invoke | ||
const val = this.get(); | ||
if (val !== undefined) { | ||
try { | ||
listener(val); | ||
if (options && options.runImmediately) { | ||
// immediately invoke | ||
const val = this.get(); | ||
if (val !== undefined) { | ||
try { | ||
listener(val); | ||
} | ||
catch (e) { | ||
unsub(); | ||
throw e; | ||
} | ||
finally { | ||
if (this.onceOnly.has(listener)) | ||
unsub(); | ||
} | ||
} | ||
catch (e) { | ||
unsub(); | ||
throw e; | ||
} | ||
} | ||
return unsub; | ||
} | ||
/** | ||
* Like listen(), but invokes the listener only once and then automatically removes it. | ||
* @param listener The observer functino to receive data. | ||
* @returns {Callback} Function to unsubscribe listener explicitly before it is called. | ||
*/ | ||
listenOnce(listener) { | ||
// check if we are reregistering the same observer | ||
if (this.listeners.has(listener) && !this.onceOnly.has(listener)) { | ||
throw new Error(`Already subscribed to listen repeatedly.`); | ||
} | ||
if (!this.onceOnly.has(listener)) { | ||
this.listeners.add(listener); | ||
this.onceOnly.add(listener); | ||
} | ||
return () => { | ||
this.listeners.delete(listener); | ||
this.onceOnly.delete(listener); | ||
}; | ||
} | ||
} |
@@ -1,2 +0,4 @@ | ||
import { getType, isObject, resolve } from "mingo/util"; | ||
import * as projectionOperators from "mingo/operators/projection"; | ||
import * as updateOperators from "mingo/operators/update"; | ||
import { getType, has, isArray, isNumber, isObject, isOperator, isString, resolve } from "mingo/util"; | ||
const KEYED_OPERATORS_MAP = Object.freeze({ | ||
@@ -12,7 +14,19 @@ $cond: { | ||
}); | ||
const peekOperator = (o) => { | ||
const keys = isObject(o) && Object.keys(o); | ||
return keys && keys.length === 1 && isOperator(keys[0]) && keys[0]; | ||
}; | ||
/** checks that value is a valid project expression. */ | ||
export const isProjectExpression = (o) => o === 1 || | ||
o === true || | ||
!!peekOperator(o) || | ||
(isString(o) && o.startsWith("$")); | ||
/** | ||
* Extract all valid field paths used in the expression. | ||
* Extract all dependent paths used in the expressions for project, match and update expressions. | ||
* | ||
* @param expr The expression. | ||
* @param options Options to customize path retrieval. | ||
*/ | ||
export function extractKeyPaths(expr, parent) { | ||
export function getDependentPaths(expr, options = { includeRootFields: false }) { | ||
const parent = options.__parent; | ||
const result = new Set(); | ||
@@ -22,8 +36,9 @@ switch (getType(expr)) { | ||
// exclude variables which all begin with "$$" | ||
if (expr.startsWith("$$")) | ||
if (expr.startsWith("$$")) { | ||
return result; | ||
} | ||
else if (expr.startsWith("$")) { | ||
result.add(expr.substring(1)); | ||
} | ||
else if (parent) { | ||
else if (parent && !isOperator(parent)) { | ||
result.add(parent); | ||
@@ -34,3 +49,3 @@ } | ||
expr | ||
.map(v => extractKeyPaths(v, parent)) | ||
.map(v => getDependentPaths(v, options)) | ||
.forEach(s => s.forEach(v => result.add(v))); | ||
@@ -44,5 +59,6 @@ break; | ||
// handle top-level boolean operators ($and, $or,..) and $expr. | ||
if (key.startsWith("$")) { | ||
if (isOperator(key)) { | ||
let val2 = val; | ||
// handle operators with keyed arguments. | ||
// this ensure we process each leaf object correctly and don't treat the leaf itself as a field in our state. | ||
const opts = KEYED_OPERATORS_MAP[key]; | ||
@@ -54,7 +70,44 @@ if (opts && typeof val === "object") { | ||
} | ||
extractKeyPaths(val2, parent).forEach(v => result.add(v)); | ||
getDependentPaths(val2, Object.assign(Object.assign({}, options), { | ||
// for update expressions send the key as the parent, since they are always top-level. | ||
__parent: !parent && has(updateOperators, key) ? key : parent })).forEach(v => result.add(v)); | ||
} | ||
else { | ||
const ancestor = parent ? parent + "." + key : key; | ||
extractKeyPaths(val, ancestor).forEach(v => result.add(v)); | ||
// handle update operators first. we pass the operator as the parent since that should always be the top-level field. | ||
// extracting fields for update expressions is not used yet, but may be leveraged for optimizations latter. | ||
if (isOperator(parent) && has(updateOperators, parent)) { | ||
getDependentPaths(val, Object.assign(Object.assign({}, options), { __parent: key })).forEach(v => result.add(v)); | ||
continue; | ||
} | ||
if (!options.includeRootFields && | ||
!parent && | ||
!isProjectExpression(val)) { | ||
// skip if not a valid project expression or ignoring root fields. | ||
continue; | ||
} | ||
let ancestor = parent ? parent + "." + key : key; | ||
// Since this method is written to support both $project and $match expressions we need to specially handle projection operators $elemMatch and $slice. | ||
// This avoids treating all top-level fields as valid within the state object which would not be true for projections based on expression operators. | ||
// A user may reuse field names in the state object for their selectors. We want to avoid notifying listeners if the actual dependent state fields have not changed. | ||
// To find out whether we have a projection operator, we peek into the value object to detect and operator and also check the current field is top-level. | ||
// If the operator is not a projection and the field is top-level, we don't record it as a valid state field and pass an empty value further down the extractor. | ||
const op = peekOperator(val); | ||
const valObj = val; | ||
if (op && !options.includeRootFields) { | ||
if ( | ||
// expr is not a projection operator and not nested so the top-level field must be a new alias. | ||
(!parent && !has(projectionOperators, op)) || | ||
// $slice has two flavours in MongoDB, so we need to make sure we are looking at the correct one when first condition fails. | ||
// if nested (i.e parent exists), then we know we are using the $slice from the expression operators. | ||
(parent && op === "$slice") || | ||
// if no parent but op is $slice, we need to check the actual type to determine. | ||
// validates for $slice as an expression operator. | ||
(!parent && | ||
op === "$slice" && | ||
isArray(valObj["$slice"]) && | ||
!isNumber(valObj["$slice"][0]))) { | ||
ancestor = undefined; | ||
} | ||
} | ||
getDependentPaths(val, Object.assign(Object.assign({}, options), { __parent: ancestor })).forEach(v => result.add(v)); | ||
} | ||
@@ -61,0 +114,0 @@ } |
import { Query } from "mingo"; | ||
import { Options as QueryOptions, UpdateOptions } from "mingo/core"; | ||
import { AnyVal, Callback, RawObject } from "mingo/types"; | ||
import { AnyVal, RawObject } from "mingo/types"; | ||
import { UpdateExpression } from "mingo/updater"; | ||
/** Observes a selector for changes in store and optionally return updates to apply. */ | ||
export type Listener<T extends RawObject> = Callback<void, T>; | ||
export type Listener<T> = (data: T) => void; | ||
/** Unsbuscribe from receiving further notifications */ | ||
export type Unsubscribe = () => void; | ||
/** Options to pass on subscription. */ | ||
export interface SubscribeOptions { | ||
/** Immediately run the listener when register. Any error will bubble up immediately. */ | ||
readonly runImmediately?: boolean; | ||
/** Run only once. */ | ||
readonly runOnce?: boolean; | ||
} | ||
/** Result from update operation which returns useful details. */ | ||
export type UpdateResult = { | ||
/** Indicates whether the state was modified */ | ||
export interface UpdateResult { | ||
/** Represents whether the state was modified */ | ||
readonly modified: boolean; | ||
/** Indicates the fields in the state that were changed if modified. */ | ||
/** The fields in the state object that were modified. */ | ||
readonly fields?: string[]; | ||
/** Indicates the number of listeners notified. */ | ||
/** The number of listeners notified. */ | ||
readonly notifyCount?: number; | ||
}; | ||
} | ||
/** | ||
@@ -33,3 +42,3 @@ * Creates a new store object. | ||
private readonly selectors; | ||
private readonly hashIndex; | ||
private readonly cache; | ||
private readonly signals; | ||
@@ -41,2 +50,3 @@ private readonly queryOptions; | ||
* Creates a new observable for a view of the state. | ||
* | ||
* @param projection Fields of the state to view. Expressed as MongoDB projection query. | ||
@@ -46,6 +56,7 @@ * @param condition Conditions to match for a valid state view. Expressed as MongoDB filter query. | ||
*/ | ||
select<P extends RawObject>(projection: Record<keyof P, AnyVal>, condition?: RawObject): Selector<P>; | ||
select<P extends RawObject>(projection: Record<keyof P, AnyVal> | RawObject, condition?: RawObject): Selector<P>; | ||
/** | ||
* Dispatches an update expression to mutate the state. Triggers a notification to relevant selectors only. | ||
* @param {RawObject} expr Update expression as a MongoDB update query. | ||
* | ||
* @param {UpdateExpression} expr Update expression as a MongoDB update query. | ||
* @param {Array<RawObject>} arrayFilters Array filter expressions to filter elements to update. | ||
@@ -77,3 +88,3 @@ * @param {RawObject} condition Condition to check before applying update. | ||
*/ | ||
constructor(state: RawObject, projection: Record<keyof T, AnyVal>, query: Query, options: QueryOptions); | ||
constructor(state: RawObject, projection: Record<keyof T, AnyVal> | RawObject, query: Query, options: QueryOptions); | ||
/** Returns the number of subscribers to this selector. */ | ||
@@ -97,19 +108,8 @@ get size(): number; | ||
/** | ||
* Register a listener to be notified about state updates. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
* Subscribe a listener to be notified about state updates. | ||
* | ||
* @param listener The function to receive new data on update. | ||
* @returns {Unsubscribe} Function to unsubscribe listener. | ||
*/ | ||
listen(listener: Listener<T>): Callback<void>; | ||
/** | ||
* Like listen() but also immediately invoke the listener if a value is pending for selector. | ||
* @param listener The observer function to receive data. | ||
* @returns {Callback} Function to unsubscribe listener. | ||
*/ | ||
listenNow(listener: Listener<T>): Callback<void>; | ||
/** | ||
* Like listen(), but invokes the listener only once and then automatically removes it. | ||
* @param listener The observer functino to receive data. | ||
* @returns {Callback} Function to unsubscribe listener explicitly before it is called. | ||
*/ | ||
listenOnce(listener: Listener<T>): Callback<void>; | ||
subscribe(listener: Listener<T>, options?: SubscribeOptions): Unsubscribe; | ||
} |
import { AnyVal } from "mingo/types"; | ||
/** checks that value is a valid project expression. */ | ||
export declare const isProjectExpression: (o: AnyVal) => boolean; | ||
export interface GetDependentPathOptions { | ||
/** Assumes top-level fields are part of the state and includes them. */ | ||
includeRootFields: boolean; | ||
/** used internally */ | ||
__parent?: string; | ||
} | ||
/** | ||
* Extract all valid field paths used in the expression. | ||
* Extract all dependent paths used in the expressions for project, match and update expressions. | ||
* | ||
* @param expr The expression. | ||
* @param options Options to customize path retrieval. | ||
*/ | ||
export declare function extractKeyPaths(expr: AnyVal, parent?: string): Set<string>; | ||
export declare function getDependentPaths(expr: AnyVal, options?: GetDependentPathOptions): Set<string>; | ||
/** | ||
@@ -8,0 +18,0 @@ * Determines if the selector has a common ancestor with any other in the set. |
{ | ||
"name": "adaka", | ||
"version": "0.0.8", | ||
"version": "0.0.9", | ||
"description": "High-precision state management using MongoDB query language.", | ||
@@ -5,0 +5,0 @@ "main": "./dist/cjs/index.js", |
@@ -50,3 +50,3 @@ # adaka | ||
// subcriber runs whenever name changes. | ||
const unsubscribe = selector.listen(view => { | ||
const unsubscribe = selector.subscribe(view => { | ||
console.log("->", view); | ||
@@ -93,3 +93,3 @@ }); | ||
selector.listen(data => { | ||
selector.subscribe(data => { | ||
console.log("->", data); | ||
@@ -96,0 +96,0 @@ }); |
51945
982