@instantdb/core
Version:
Instant's core local abstraction
758 lines (662 loc) • 18.4 kB
JavaScript
import { query as datalogQuery } from './datalog.js';
import { uuidCompare } from './utils/uuid.ts';
import * as s from './store.js';
// Pattern variables
// -----------------
let _seed = 0;
function wildcard(friendlyName) {
return makeVarImpl(`_${friendlyName}`, _seed++);
}
function makeVarImpl(x, level) {
return `?${x}-${level}`;
}
// Where
// -----------------
class AttrNotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'AttrNotFoundError';
}
}
function idAttr(store, ns) {
const attr = s.getPrimaryKeyAttr(store, ns);
if (!attr) {
throw new AttrNotFoundError(`Could not find id attr for ${ns}`);
}
return attr;
}
function defaultWhere(makeVar, store, etype, level) {
return [eidWhere(makeVar, store, etype, level)];
}
function eidWhere(makeVar, store, etype, level) {
return [
makeVar(etype, level),
idAttr(store, etype).id,
makeVar(etype, level),
makeVar('time', level),
];
}
function replaceInAttrPat(attrPat, needle, v) {
return attrPat.map((x) => (x === needle ? v : x));
}
function refAttrPat(makeVar, store, etype, level, label) {
const fwdAttr = s.getAttrByFwdIdentName(store, etype, label);
const revAttr = s.getAttrByReverseIdentName(store, etype, label);
const attr = fwdAttr || revAttr;
if (!attr) {
throw new AttrNotFoundError(`Could not find attr for ${[etype, label]}`);
}
if (attr['value-type'] !== 'ref') {
throw new Error(`Attr ${attr.id} is not a ref`);
}
const [_f, fwdEtype] = attr['forward-identity'];
const [_r, revEtype] = attr['reverse-identity'];
const nextLevel = level + 1;
const attrPat = fwdAttr
? [
makeVar(fwdEtype, level),
attr.id,
makeVar(revEtype, nextLevel),
wildcard('time'),
]
: [
makeVar(fwdEtype, nextLevel),
attr.id,
makeVar(revEtype, level),
wildcard('time'),
];
const nextEtype = fwdAttr ? revEtype : fwdEtype;
const isForward = Boolean(fwdAttr);
return [nextEtype, nextLevel, attrPat, attr, isForward];
}
function makeLikeMatcher(caseSensitive, pattern) {
if (typeof pattern !== 'string') {
return function likeMatcher(_value) {
return false;
};
}
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexPattern = escapedPattern.replace(/%/g, '.*').replace(/_/g, '.');
const regex = new RegExp(
`^${regexPattern}$`,
caseSensitive ? undefined : 'i',
);
return function likeMatcher(value) {
if (typeof value !== 'string') {
return false;
}
return regex.test(value);
};
}
function parseValue(attr, v) {
if (
typeof v !== 'object' ||
v.hasOwnProperty('$in') ||
v.hasOwnProperty('in')
) {
return v;
}
const isDate = attr['checked-data-type'] === 'date';
if (v.hasOwnProperty('$gt')) {
return {
$comparator: true,
$op: isDate
? function gtDate(triple) {
return new Date(triple[2]) > new Date(v.$gt);
}
: function gt(triple) {
return triple[2] > v.$gt;
},
};
}
if (v.hasOwnProperty('$gte')) {
return {
$comparator: true,
$op: isDate
? function gteDate(triple) {
return new Date(triple[2]) >= new Date(v.$gte);
}
: function gte(triple) {
return triple[2] >= v.$gte;
},
};
}
if (v.hasOwnProperty('$lt')) {
return {
$comparator: true,
$op: isDate
? function ltDate(triple) {
return new Date(triple[2]) < new Date(v.$lt);
}
: function lt(triple) {
return triple[2] < v.$lt;
},
};
}
if (v.hasOwnProperty('$lte')) {
return {
$comparator: true,
$op: isDate
? function lteDate(triple) {
return new Date(triple[2]) <= new Date(v.$lte);
}
: function lte(triple) {
return triple[2] <= v.$lte;
},
};
}
if (v.hasOwnProperty('$like')) {
const matcher = makeLikeMatcher(true, v.$like);
return {
$comparator: true,
$op: function like(triple) {
return matcher(triple[2]);
},
};
}
if (v.hasOwnProperty('$ilike')) {
const matcher = makeLikeMatcher(false, v.$ilike);
return {
$comparator: true,
$op: function ilike(triple) {
return matcher(triple[2]);
},
};
}
return v;
}
function valueAttrPat(makeVar, store, valueEtype, valueLevel, valueLabel, v) {
const fwdAttr = s.getAttrByFwdIdentName(store, valueEtype, valueLabel);
const revAttr = s.getAttrByReverseIdentName(store, valueEtype, valueLabel);
const attr = fwdAttr || revAttr;
if (!attr) {
throw new AttrNotFoundError(
`No attr for etype = ${valueEtype} label = ${valueLabel}`,
);
}
if (v?.hasOwnProperty('$isNull')) {
const idAttr = s.getAttrByFwdIdentName(store, valueEtype, 'id');
if (!idAttr) {
throw new AttrNotFoundError(
`No attr for etype = ${valueEtype} label = id`,
);
}
return [
makeVar(valueEtype, valueLevel),
idAttr.id,
{ $isNull: { attrId: attr.id, isNull: v.$isNull, reverse: !fwdAttr } },
wildcard('time'),
];
}
if (fwdAttr) {
return [
makeVar(valueEtype, valueLevel),
attr.id,
parseValue(attr, v),
wildcard('time'),
];
}
return [v, attr.id, makeVar(valueEtype, valueLevel), wildcard('time')];
}
function refAttrPats(makeVar, store, etype, level, refsPath) {
const [lastEtype, lastLevel, attrPats] = refsPath.reduce(
(acc, label) => {
const [etype, level, attrPats] = acc;
const [nextEtype, nextLevel, attrPat] = refAttrPat(
makeVar,
store,
etype,
level,
label,
);
return [nextEtype, nextLevel, [...attrPats, attrPat]];
},
[etype, level, []],
);
return [lastEtype, lastLevel, attrPats];
}
function whereCondAttrPats(makeVar, store, etype, level, path, v) {
const refsPath = path.slice(0, path.length - 1);
const valueLabel = path[path.length - 1];
const [lastEtype, lastLevel, refPats] = refAttrPats(
makeVar,
store,
etype,
level,
refsPath,
);
const valuePat = valueAttrPat(
makeVar,
store,
lastEtype,
lastLevel,
valueLabel,
v,
);
return refPats.concat([valuePat]);
}
function withJoin(where, join) {
return join ? [join].concat(where) : where;
}
function isOrClauses([k, v]) {
return k === 'or' && Array.isArray(v);
}
function isAndClauses([k, v]) {
return k === 'and' && Array.isArray(v);
}
// Creates a makeVar that will namespace symbols for or clauses
// to prevent conflicts, except for the base etype
function genMakeVar(baseMakeVar, joinSym, orIdx) {
return (x, lvl) => {
const base = baseMakeVar(x, lvl);
if (joinSym == base) {
return base;
}
return `${base}-${orIdx}`;
};
}
function parseWhereClauses(
makeVar,
clauseType /* 'or' | 'and' */,
store,
etype,
level,
whereValue,
) {
const joinSym = makeVar(etype, level);
const patterns = whereValue.map((w, i) => {
const makeNamespacedVar = genMakeVar(makeVar, joinSym, i);
return parseWhere(makeNamespacedVar, store, etype, level, w);
});
return { [clauseType]: { patterns, joinSym } };
}
// Given a path, returns a list of paths leading up to this path:
// growPath([1, 2, 3]) -> [[1], [1, 2], [1, 2, 3]]
function growPath(path) {
const ret = [];
for (let i = 1; i <= path.length; i++) {
ret.push(path.slice(0, i));
}
return ret;
}
// Returns array of pattern arrays that should be grouped in OR
// to capture any intermediate nulls
function whereCondAttrPatsForNullIsTrue(makeVar, store, etype, level, path) {
return growPath(path).map((path) =>
whereCondAttrPats(makeVar, store, etype, level, path, { $isNull: true }),
);
}
function parseWhere(makeVar, store, etype, level, where) {
return Object.entries(where).flatMap(([k, v]) => {
if (isOrClauses([k, v])) {
return parseWhereClauses(makeVar, 'or', store, etype, level, v);
}
if (isAndClauses([k, v])) {
return parseWhereClauses(makeVar, 'and', store, etype, level, v);
}
// Temporary hack until we have support for a uuid index on `id`
if (k === '$entityIdStartsWith') {
return [];
}
const path = k.split('.');
if (v?.hasOwnProperty('$not')) {
// `$not` won't pick up entities that are missing the attr, so we
// add in a `$isNull` to catch those too.
const notPats = whereCondAttrPats(makeVar, store, etype, level, path, v);
const nilPats = whereCondAttrPatsForNullIsTrue(
makeVar,
store,
etype,
level,
path,
);
return [
{
or: {
patterns: [notPats, ...nilPats],
joinSym: makeVar(etype, level),
},
},
];
}
if (v?.hasOwnProperty('$isNull') && v.$isNull === true && path.length > 1) {
// Make sure we're capturing all of the intermediate paths that might be null
// by checking for null at each step along the path
return [
{
or: {
patterns: whereCondAttrPatsForNullIsTrue(
makeVar,
store,
etype,
level,
path,
),
joinSym: makeVar(etype, level),
},
},
];
}
return whereCondAttrPats(makeVar, store, etype, level, path, v);
});
}
function makeWhere(store, etype, level, where) {
const makeVar = makeVarImpl;
if (!where) {
return defaultWhere(makeVar, store, etype, level);
}
const parsedWhere = parseWhere(makeVar, store, etype, level, where);
return parsedWhere.concat(defaultWhere(makeVar, store, etype, level));
}
// Find
// -----------------
function makeFind(makeVar, etype, level) {
return [makeVar(etype, level), makeVar('time', level)];
}
// extendObjects
// -----------------
function makeJoin(makeVar, store, etype, level, label, eid) {
const [nextEtype, nextLevel, pat, attr, isForward] = refAttrPat(
makeVar,
store,
etype,
level,
label,
);
const actualized = replaceInAttrPat(pat, makeVar(etype, level), eid);
return [nextEtype, nextLevel, actualized, attr, isForward];
}
function extendObjects(makeVar, store, { etype, level, form }, objects) {
const childQueries = Object.keys(form).filter((c) => c !== '$');
if (!childQueries.length) {
return Object.values(objects);
}
return Object.entries(objects).map(function extendChildren([eid, parent]) {
const childResults = childQueries.map(function getChildResult(label) {
const isSingular = Boolean(
store.cardinalityInference &&
store.linkIndex?.[etype]?.[label]?.isSingular,
);
try {
const [nextEtype, nextLevel, join] = makeJoin(
makeVar,
store,
etype,
level,
label,
eid,
);
const childrenArray = queryOne(store, {
etype: nextEtype,
level: nextLevel,
form: form[label],
join,
});
const childOrChildren = isSingular ? childrenArray[0] : childrenArray;
return { [label]: childOrChildren };
} catch (e) {
if (e instanceof AttrNotFoundError) {
return { [label]: isSingular ? undefined : [] };
}
throw e;
}
});
return childResults.reduce(function reduceChildren(parent, child) {
return { ...parent, ...child };
}, parent);
});
}
// resolveObjects
// -----------------
function shouldIgnoreAttr(attrs, id) {
const attr = attrs[id];
return attr['value-type'] === 'ref' && attr['forward-identity'][2] !== 'id';
}
function compareOrder([id_a, v_a], [id_b, v_b]) {
if (v_a === v_b || (v_a == null && v_b == null)) {
return uuidCompare(id_a, id_b);
}
if (v_b == null) {
return 1;
}
if (v_a == null) {
return -1;
}
if (v_a > v_b) {
return 1;
}
return -1;
}
function comparableDate(x) {
if (x == null) {
return x;
}
return new Date(x).getTime();
}
function isBefore(startCursor, orderAttr, direction, idVec) {
const [c_e, _c_a, c_v, c_t] = startCursor;
const compareVal = direction === 'desc' ? 1 : -1;
if (orderAttr['forward-identity']?.[2] === 'id') {
return compareOrder(idVec, [c_e, c_t]) === compareVal;
}
const [e, v] = idVec;
const v_new =
orderAttr['checked-data-type'] === 'date' ? comparableDate(v) : v;
const c_v_new =
orderAttr['checked-data-type'] === 'date' ? comparableDate(c_v) : c_v;
return compareOrder([e, v_new], [c_e, c_v_new]) === compareVal;
}
function orderAttrFromCursor(store, cursor) {
const cursorAttrId = cursor[1];
return store.attrs[cursorAttrId];
}
function orderAttrFromOrder(store, etype, order) {
const label = Object.keys(order)[0];
return s.getAttrByFwdIdentName(store, etype, label);
}
function getOrderAttr(store, etype, cursor, order) {
if (cursor) {
return orderAttrFromCursor(store, cursor);
}
if (order) {
return orderAttrFromOrder(store, etype, order);
}
}
function objectAttrs(store, etype, dq) {
if (!Array.isArray(dq.fields)) {
return s.getBlobAttrs(store, etype);
}
const attrs = new Map();
for (const field of dq.fields) {
const attr = s.getAttrByFwdIdentName(store, etype, field);
const label = attr?.['forward-identity']?.[2];
if (label && s.isBlob(attr)) {
attrs.set(label, attr);
}
}
// Ensure we add the id field to avoid empty objects
if (!attrs.has('id')) {
const attr = s.getAttrByFwdIdentName(store, etype, 'id');
const label = attr?.['forward-identity']?.[2];
if (label) {
attrs.set(label, attr);
}
}
return attrs;
}
function runDataloadAndReturnObjects(
store,
etype,
direction,
pageInfo,
order,
dq,
) {
let idVecs = datalogQuery(store, dq);
const startCursor = pageInfo?.['start-cursor'];
const orderAttr = getOrderAttr(store, etype, startCursor, order);
if (orderAttr && orderAttr?.['forward-identity']?.[2] !== 'id') {
const isDate = orderAttr['checked-data-type'] === 'date';
const a = orderAttr.id;
idVecs = idVecs.map(([id]) => {
// order attr is required to be cardinality one, so there will
// be at most one value here
let v = store.eav.get(id)?.get(a)?.values()?.next()?.value?.[2];
if (isDate) {
v = comparableDate(v);
}
return [id, v];
});
}
idVecs.sort(
direction === 'asc'
? function compareIdVecs(a, b) {
return compareOrder(a, b);
}
: function compareIdVecs(a, b) {
return compareOrder(b, a);
},
);
let objects = {};
const attrs = objectAttrs(store, etype, dq);
for (const idVec of idVecs) {
const [id] = idVec;
if (objects[id]) {
continue;
}
if (
startCursor &&
orderAttr &&
isBefore(startCursor, orderAttr, direction, idVec)
) {
continue;
}
const obj = s.getAsObject(store, attrs, id);
if (obj) {
objects[id] = obj;
}
}
return objects;
}
function determineOrder(form) {
const orderOpts = form.$?.order;
if (!orderOpts) {
return 'asc';
}
return orderOpts[Object.keys(orderOpts)[0]] || 'asc';
}
/**
* Given a query like:
*
* {
* users: {
* $: { where: { name: "Joe" } },
* },
* };
*
* `resolveObjects`, turns where clause: `{ name: "Joe" }`
* into a datalog query. We then run the datalog query,
* and reduce all the triples into objects.
*/
function resolveObjects(store, { etype, level, form, join, pageInfo }) {
const limit = form.$?.limit || form.$?.first || form.$?.last;
const offset = form.$?.offset;
const before = form.$?.before;
const after = form.$?.after;
const order = form.$?.order;
const fields = form.$?.fields;
// Wait for server to tell us where we start if we don't start from the beginning
if ((offset || before || after) && (!pageInfo || !pageInfo['start-cursor'])) {
return [];
}
const where = withJoin(makeWhere(store, etype, level, form.$?.where), join);
const find = makeFind(makeVarImpl, etype, level);
const objs = runDataloadAndReturnObjects(
store,
etype,
determineOrder(form),
pageInfo,
order,
{ where, find, fields },
);
if (limit != null) {
const entries = Object.entries(objs);
if (entries.length <= limit) {
return objs;
}
return Object.fromEntries(entries.slice(0, limit));
}
return objs;
}
/**
* It's possible that we query
* for an attribute that doesn't exist yet.
*
* { users: { $: { where: { nonExistentProperty: "foo" } } } }
*
* This swallows the missing attr error and returns
* an empty result instead
*/
function guardedResolveObjects(store, opts) {
try {
return resolveObjects(store, opts);
} catch (e) {
if (e instanceof AttrNotFoundError) {
return {};
}
throw e;
}
}
/**
* Given a query like:
*
* {
* users: {
* $: { where: { name: "Joe" } },
* posts: {},
* },
* };
*
* `guardResolveObjects` will return the relevant `users` objects
* `extendObjects` will then extend each `user` object with relevant `posts`.
*/
function queryOne(store, opts) {
const objects = guardedResolveObjects(store, opts);
return extendObjects(makeVarImpl, store, opts, objects);
}
function formatPageInfo(pageInfo) {
const res = {};
for (const [k, v] of Object.entries(pageInfo)) {
res[k] = {
startCursor: v['start-cursor'],
endCursor: v['end-cursor'],
hasNextPage: v['has-next-page?'],
hasPreviousPage: v['has-previous-page?'],
};
}
return res;
}
export default function query({ store, pageInfo, aggregate }, q) {
const data = Object.keys(q).reduce(function reduceResult(res, k) {
if (aggregate?.[k] || '$$ruleParams' === k) {
// Aggregate doesn't return any join rows and has no children,
// so don't bother querying further
return res;
}
res[k] = queryOne(store, {
etype: k,
form: q[k],
level: 0,
pageInfo: pageInfo?.[k],
});
return res;
}, {});
const result = { data };
if (pageInfo) {
result.pageInfo = formatPageInfo(pageInfo);
}
if (aggregate) {
result.aggregate = aggregate;
}
return result;
}