snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
45
packages/mongo-adapter/src/bson.spec.ts
Normal file
45
packages/mongo-adapter/src/bson.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getBSONType, compareBSONValues } from './bson';
|
||||
import { BSONType } from './types';
|
||||
|
||||
describe('getBSONType', () => {
|
||||
it('should work', () => {
|
||||
expect(getBSONType(1)).toBe(BSONType.Double);
|
||||
expect(getBSONType('xyz')).toBe(BSONType.String);
|
||||
expect(getBSONType({})).toBe(BSONType.Object);
|
||||
expect(getBSONType([])).toBe(BSONType.Array);
|
||||
expect(getBSONType(new Uint8Array())).toBe(BSONType.BinData);
|
||||
expect(getBSONType(undefined)).toBe(BSONType.Object);
|
||||
expect(getBSONType(null)).toBe(BSONType.Null);
|
||||
expect(getBSONType(false)).toBe(BSONType.Boolean);
|
||||
expect(getBSONType(/.*/)).toBe(BSONType.Regex);
|
||||
expect(getBSONType(() => true)).toBe(BSONType.JavaScript);
|
||||
expect(getBSONType(new Date(0))).toBe(BSONType.Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareBSONValues', () => {
|
||||
it('should work for the same types', () => {
|
||||
expect(compareBSONValues(2, 3)).toBe(-1);
|
||||
expect(compareBSONValues(2, 4)).toBe(-2);
|
||||
expect(compareBSONValues('xyz', 'abc')).toBe(1);
|
||||
expect(compareBSONValues({}, {})).toBe(0);
|
||||
expect(compareBSONValues(true, false)).toBe(1);
|
||||
expect(compareBSONValues(new Date(0), new Date(1))).toBe(-1);
|
||||
});
|
||||
|
||||
it('should work for different types', () => {
|
||||
expect(compareBSONValues(2, null)).toBe(1);
|
||||
expect(compareBSONValues('xyz', {})).toBe(-1);
|
||||
expect(compareBSONValues(false, 3)).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
expect(compareBSONValues([1, 2], [1, 3])).toBe(-1);
|
||||
expect(compareBSONValues([1, 2], [1, 2])).toBe(0);
|
||||
expect(compareBSONValues([1, 2], [1, 2, 3])).toBe(-1);
|
||||
expect(compareBSONValues([1, 2, 3], [1, 2])).toBe(1);
|
||||
expect(compareBSONValues([], [])).toBe(0);
|
||||
expect(compareBSONValues([10, 20, 30], [5, 15, 25])).toBe(5);
|
||||
expect(compareBSONValues([5, 15, 25], [10, 20, 30])).toBe(-5);
|
||||
});
|
||||
});
|
||||
184
packages/mongo-adapter/src/bson.ts
Normal file
184
packages/mongo-adapter/src/bson.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { BSONType } from './types';
|
||||
|
||||
export const getBSONType = <T>(v: T): BSONType => {
|
||||
if (typeof v === 'number') {
|
||||
return BSONType.Double;
|
||||
}
|
||||
|
||||
if (typeof v === 'string') {
|
||||
return BSONType.String;
|
||||
}
|
||||
|
||||
if (typeof v === 'boolean') {
|
||||
return BSONType.Boolean;
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
return BSONType.Array;
|
||||
}
|
||||
|
||||
if (v === null) {
|
||||
return BSONType.Null;
|
||||
}
|
||||
|
||||
if (v instanceof RegExp) {
|
||||
return BSONType.Regex;
|
||||
}
|
||||
|
||||
if (typeof v === 'function') {
|
||||
return BSONType.JavaScript;
|
||||
}
|
||||
|
||||
if (v instanceof Date) {
|
||||
return BSONType.Date;
|
||||
}
|
||||
|
||||
if (v instanceof Uint8Array) {
|
||||
return BSONType.BinData;
|
||||
}
|
||||
|
||||
return BSONType.Object;
|
||||
};
|
||||
|
||||
const getBSONTypeOrder = (type: BSONType): number => {
|
||||
switch (type) {
|
||||
case BSONType.Null:
|
||||
return 0;
|
||||
|
||||
case BSONType.Double:
|
||||
case BSONType.Int:
|
||||
case BSONType.Long:
|
||||
return 1;
|
||||
|
||||
case BSONType.String:
|
||||
case BSONType.Symbol:
|
||||
return 2;
|
||||
|
||||
case BSONType.Object:
|
||||
return 3;
|
||||
|
||||
case BSONType.Array:
|
||||
return 4;
|
||||
|
||||
case BSONType.BinData:
|
||||
return 5;
|
||||
|
||||
case BSONType.ObjectId:
|
||||
return 6;
|
||||
|
||||
case BSONType.Boolean:
|
||||
return 7;
|
||||
|
||||
case BSONType.Date:
|
||||
case BSONType.Timestamp:
|
||||
return 8;
|
||||
|
||||
case BSONType.Regex:
|
||||
return 9;
|
||||
|
||||
case BSONType.JavaScript:
|
||||
case BSONType.JavaScriptWithScope:
|
||||
return 100;
|
||||
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
type ObjectID = {
|
||||
toHexString(): string;
|
||||
equals(otherID: ObjectID): boolean;
|
||||
};
|
||||
|
||||
export const compareBSONValues = (a: unknown, b: unknown): number => {
|
||||
if (a === undefined) {
|
||||
return b === undefined ? 0 : -1;
|
||||
}
|
||||
|
||||
if (b === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const ta = getBSONType(a);
|
||||
const oa = getBSONTypeOrder(ta);
|
||||
|
||||
const tb = getBSONType(b);
|
||||
const ob = getBSONTypeOrder(tb);
|
||||
|
||||
if (oa !== ob) {
|
||||
return oa < ob ? -1 : 1;
|
||||
}
|
||||
|
||||
if (ta !== tb) {
|
||||
throw Error('Missing type coercion logic in compareBSONValues');
|
||||
}
|
||||
|
||||
switch (ta) {
|
||||
case BSONType.Double:
|
||||
return (a as number) - (b as number);
|
||||
|
||||
case BSONType.String:
|
||||
return (a as string).localeCompare(b as string);
|
||||
|
||||
case BSONType.Object:
|
||||
return compareBSONValues(
|
||||
Array.prototype.concat.call([], ...Object.entries(a as Record<string, unknown>)),
|
||||
Array.prototype.concat.call([], ...Object.entries(b as Record<string, unknown>)),
|
||||
);
|
||||
|
||||
case BSONType.Array: {
|
||||
for (let i = 0; ; i++) {
|
||||
if (i === (a as unknown[]).length) {
|
||||
return i === (b as unknown[]).length ? 0 : -1;
|
||||
}
|
||||
|
||||
if (i === (b as unknown[]).length) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const s = compareBSONValues((a as unknown[])[i], (b as unknown[])[i]);
|
||||
if (s !== 0) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case BSONType.BinData: {
|
||||
if ((a as Uint8Array).length !== (b as Uint8Array).length) {
|
||||
return (a as Uint8Array).length - (b as Uint8Array).length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < (a as Uint8Array).length; i++) {
|
||||
if ((a as Uint8Array)[i] === (b as Uint8Array)[i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (a as Uint8Array)[i] < (b as Uint8Array)[i] ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
case BSONType.Null:
|
||||
case BSONType.Undefined:
|
||||
return 0;
|
||||
|
||||
case BSONType.ObjectId:
|
||||
return (a as ObjectID).toHexString().localeCompare((b as ObjectID).toHexString());
|
||||
|
||||
case BSONType.Boolean:
|
||||
return Number(a) - Number(b);
|
||||
|
||||
case BSONType.Date:
|
||||
return (a as Date).getTime() - (b as Date).getTime();
|
||||
|
||||
case BSONType.Regex:
|
||||
throw Error('Sorting not supported on regular expression');
|
||||
|
||||
case BSONType.JavaScript:
|
||||
case BSONType.JavaScriptWithScope:
|
||||
throw Error('Sorting not supported on Javascript code');
|
||||
}
|
||||
|
||||
throw Error('Unknown type to sort');
|
||||
};
|
||||
298
packages/mongo-adapter/src/common.spec.ts
Normal file
298
packages/mongo-adapter/src/common.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
assertHasValidFieldNames,
|
||||
assertIsValidFieldName,
|
||||
isBinary,
|
||||
clone,
|
||||
isNumericKey,
|
||||
isPlainObject,
|
||||
isIndexable,
|
||||
equals,
|
||||
isEmptyArray,
|
||||
} from './common';
|
||||
|
||||
describe('assertIsValidFieldName', () => {
|
||||
it('should not throw for valid field names', () => {
|
||||
expect(() => assertIsValidFieldName('validName')).not.toThrow();
|
||||
expect(() => assertIsValidFieldName('valid_name_with_underscores')).not.toThrow();
|
||||
expect(() => assertIsValidFieldName('validNameWithNumbers123')).not.toThrow();
|
||||
expect(() => assertIsValidFieldName('')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for fields starting with $', () => {
|
||||
expect(() => assertIsValidFieldName('$invalidName')).toThrow("Key $invalidName must not start with '$'");
|
||||
});
|
||||
|
||||
it('should throw for fields containing .', () => {
|
||||
expect(() => assertIsValidFieldName('invalid.name')).toThrow("Key invalid.name must not contain '.'");
|
||||
});
|
||||
|
||||
it('should throw for fields containing null bytes', () => {
|
||||
expect(() => assertIsValidFieldName('invalid\0name')).toThrow('Key invalid\0name must not contain null bytes');
|
||||
});
|
||||
|
||||
it('should return undefined for non-string keys', () => {
|
||||
// @ts-expect-error - Testing with invalid input
|
||||
expect(assertIsValidFieldName(123)).toBeUndefined();
|
||||
// @ts-expect-error - Testing with invalid input
|
||||
expect(assertIsValidFieldName(null)).toBeUndefined();
|
||||
// @ts-expect-error - Testing with invalid input
|
||||
expect(assertIsValidFieldName(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertHasValidFieldNames', () => {
|
||||
it('should not throw for documents with valid field names', () => {
|
||||
expect(() => assertHasValidFieldNames({ validField: 'value' })).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames({ nested: { valid: 'value' } })).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames([{ valid: 'value' }])).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames(null)).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames(undefined)).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames(123)).not.toThrow();
|
||||
expect(() => assertHasValidFieldNames('string')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for documents with invalid field names', () => {
|
||||
expect(() => assertHasValidFieldNames({ $invalid: 'value' })).toThrow("Key $invalid must not start with '$'");
|
||||
expect(() => assertHasValidFieldNames({ 'invalid.field': 'value' })).toThrow("Key invalid.field must not contain '.'");
|
||||
expect(() => assertHasValidFieldNames({ 'invalid\0field': 'value' })).toThrow('Key invalid\0field must not contain null bytes');
|
||||
expect(() => assertHasValidFieldNames({ valid: { $invalid: 'value' } })).toThrow("Key $invalid must not start with '$'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBinary', () => {
|
||||
it('should return true for Uint8Array instances', () => {
|
||||
expect(isBinary(new Uint8Array())).toBe(true);
|
||||
expect(isBinary(new Uint8Array([1, 2, 3]))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-Uint8Array values', () => {
|
||||
expect(isBinary(null)).toBe(false);
|
||||
expect(isBinary(undefined)).toBe(false);
|
||||
expect(isBinary('string')).toBe(false);
|
||||
expect(isBinary(123)).toBe(false);
|
||||
expect(isBinary({})).toBe(false);
|
||||
expect(isBinary([])).toBe(false);
|
||||
expect(isBinary(new Date())).toBe(false);
|
||||
expect(isBinary(new RegExp(''))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clone', () => {
|
||||
it('should return primitives as is', () => {
|
||||
expect(clone(1)).toBe(1);
|
||||
expect(clone('string')).toBe('string');
|
||||
expect(clone(true)).toBe(true);
|
||||
expect(clone(false)).toBe(false);
|
||||
expect(clone(undefined)).toBe(undefined);
|
||||
expect(clone(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('should clone Date objects', () => {
|
||||
const date = new Date();
|
||||
const cloned = clone(date);
|
||||
|
||||
expect(cloned).toEqual(date);
|
||||
expect(cloned).not.toBe(date); // Different reference
|
||||
expect(cloned.getTime()).toBe(date.getTime());
|
||||
});
|
||||
|
||||
it('should return RegExp objects as is (same reference)', () => {
|
||||
const regex = /test/g;
|
||||
const cloned = clone(regex);
|
||||
|
||||
expect(cloned).toBe(regex); // Same reference
|
||||
});
|
||||
|
||||
it('should clone Uint8Array objects', () => {
|
||||
const binary = new Uint8Array([1, 2, 3]);
|
||||
const cloned = clone(binary);
|
||||
|
||||
expect(cloned).toEqual(binary);
|
||||
expect(cloned).not.toBe(binary); // Different reference
|
||||
expect(cloned.buffer).not.toBe(binary.buffer); // Different buffer
|
||||
});
|
||||
|
||||
it('should clone arrays with nested objects', () => {
|
||||
const array = [1, { a: 2 }, [3]];
|
||||
const cloned = clone(array);
|
||||
|
||||
expect(cloned).toEqual(array);
|
||||
expect(cloned).not.toBe(array);
|
||||
expect(cloned[1]).not.toBe(array[1]);
|
||||
expect(cloned[2]).not.toBe(array[2]);
|
||||
});
|
||||
|
||||
it('should clone objects with nested objects', () => {
|
||||
const obj = { a: 1, b: { c: 2 }, d: [3] };
|
||||
const cloned = clone(obj);
|
||||
|
||||
expect(cloned).toEqual(obj);
|
||||
expect(cloned).not.toBe(obj);
|
||||
expect(cloned.b).not.toBe(obj.b);
|
||||
expect(cloned.d).not.toBe(obj.d);
|
||||
});
|
||||
|
||||
it('should handle Arguments objects', () => {
|
||||
function getArgs(..._: any) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
return arguments;
|
||||
}
|
||||
|
||||
const args = getArgs(1, 'two', { three: 3 });
|
||||
const cloned = clone(args);
|
||||
|
||||
expect(cloned).toEqual([1, 'two', { three: 3 }]);
|
||||
expect(Array.isArray(cloned)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle objects with clone method', () => {
|
||||
const obj = {
|
||||
value: 42,
|
||||
clone() {
|
||||
return { value: this.value * 2 };
|
||||
},
|
||||
};
|
||||
|
||||
const cloned = clone(obj);
|
||||
expect(cloned).toEqual({ value: 84 });
|
||||
expect(cloned).not.toBe(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNumericKey', () => {
|
||||
it('should return true for strings containing only digits', () => {
|
||||
expect(isNumericKey('0')).toBe(true);
|
||||
expect(isNumericKey('1')).toBe(true);
|
||||
expect(isNumericKey('123')).toBe(true);
|
||||
expect(isNumericKey('01')).toBe(true); // Leading zeros are fine
|
||||
});
|
||||
|
||||
it('should return false for strings not containing only digits', () => {
|
||||
expect(isNumericKey('')).toBe(false);
|
||||
expect(isNumericKey('a')).toBe(false);
|
||||
expect(isNumericKey('1a')).toBe(false);
|
||||
expect(isNumericKey('a1')).toBe(false);
|
||||
expect(isNumericKey('1.0')).toBe(false);
|
||||
expect(isNumericKey('1,000')).toBe(false);
|
||||
expect(isNumericKey('-1')).toBe(false);
|
||||
expect(isNumericKey('+1')).toBe(false);
|
||||
expect(isNumericKey(' 1')).toBe(false);
|
||||
expect(isNumericKey('1 ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
it('should return true for plain objects', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||
expect(isPlainObject(Object.create(null))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-objects', () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
expect(isPlainObject(1)).toBe(false);
|
||||
expect(isPlainObject('string')).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-plain objects', () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject(new Date())).toBe(false);
|
||||
expect(isPlainObject(/test/)).toBe(false);
|
||||
expect(isPlainObject(new Uint8Array())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIndexable', () => {
|
||||
it('should return true for arrays', () => {
|
||||
expect(isIndexable([])).toBe(true);
|
||||
expect(isIndexable([1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for plain objects', () => {
|
||||
expect(isIndexable({})).toBe(true);
|
||||
expect(isIndexable({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-indexable values', () => {
|
||||
expect(isIndexable(null)).toBe(false);
|
||||
expect(isIndexable(undefined)).toBe(false);
|
||||
expect(isIndexable(1)).toBe(false);
|
||||
expect(isIndexable('string')).toBe(false);
|
||||
expect(isIndexable(true)).toBe(false);
|
||||
expect(isIndexable(new Date())).toBe(false);
|
||||
expect(isIndexable(/test/)).toBe(false);
|
||||
expect(isIndexable(new Uint8Array())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true if two numbers are equal', () => {
|
||||
expect(equals(1, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if arguments are null or undefined', () => {
|
||||
expect(equals(undefined, null)).toBe(false);
|
||||
expect(equals(null, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if arguments arent objects and they are not the same', () => {
|
||||
expect(equals('not', 'thesame')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if date objects provided have the same value', () => {
|
||||
const currentDate = new Date();
|
||||
|
||||
expect(equals(currentDate, currentDate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if 2 equal UInt8Array are provided', () => {
|
||||
const arr1 = new Uint8Array([1, 2]);
|
||||
const arr2 = new Uint8Array([1, 2]);
|
||||
|
||||
expect(equals(arr1, arr2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if 2 equal arrays are provided', () => {
|
||||
const arr1 = [1, 2, 4];
|
||||
const arr2 = [1, 2, 4];
|
||||
|
||||
expect(equals(arr1, arr2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if 2 arrays with different length are provided', () => {
|
||||
const arr1 = [1, 4, 5];
|
||||
const arr2 = [1, 4, 5, 7];
|
||||
|
||||
expect(equals(arr1, arr2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the objects provided are "equal"', () => {
|
||||
const obj = { a: 1 };
|
||||
const obj2 = obj;
|
||||
|
||||
expect(equals(obj, obj2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if both objects have the same keys', () => {
|
||||
const obj = { a: 1 };
|
||||
const obj2 = { a: 1 };
|
||||
|
||||
expect(equals(obj, obj2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyArray', () => {
|
||||
it('should return true if array is empty', () => {
|
||||
expect(isEmptyArray([])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if value is not an array', () => {
|
||||
expect(isEmptyArray(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if array is not empty', () => {
|
||||
expect(isEmptyArray([1, 2])).toBe(false);
|
||||
});
|
||||
});
|
||||
148
packages/mongo-adapter/src/common.ts
Normal file
148
packages/mongo-adapter/src/common.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { getBSONType } from './bson';
|
||||
import { BSONType } from './types';
|
||||
|
||||
export function assertHasValidFieldNames(doc: unknown) {
|
||||
if (doc && typeof doc === 'object') {
|
||||
JSON.stringify(doc, (key, value) => {
|
||||
assertIsValidFieldName(key);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertIsValidFieldName(key: string) {
|
||||
if (typeof key !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = key.match(/^\$|\.|\0/);
|
||||
if (match) {
|
||||
switch (match[0]) {
|
||||
case '$':
|
||||
throw new Error(`Key ${key} must not start with '$'`);
|
||||
case '.':
|
||||
throw new Error(`Key ${key} must not contain '.'`);
|
||||
case '\0':
|
||||
throw new Error(`Key ${key} must not contain null bytes`);
|
||||
default:
|
||||
throw new Error(`Key ${key} contains invalid character: ${match[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isBinary = (x: unknown): x is Uint8Array => typeof x === 'object' && x !== null && x instanceof Uint8Array;
|
||||
|
||||
const isArguments = (x: unknown): x is IArguments => Object.prototype.toString.call(x) === '[object Arguments]';
|
||||
|
||||
export const clone: <T>(v: T) => T = (v: unknown) => {
|
||||
if (typeof v !== 'object') {
|
||||
return v;
|
||||
}
|
||||
|
||||
if (v === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (v instanceof Date) {
|
||||
return new Date(v.getTime());
|
||||
}
|
||||
|
||||
if (v instanceof RegExp) {
|
||||
return v;
|
||||
}
|
||||
|
||||
if (isBinary(v)) {
|
||||
const ret = new Uint8Array(new ArrayBuffer(v.length));
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
ret[i] = v[i];
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
return v.map(clone);
|
||||
}
|
||||
|
||||
if (isArguments(v)) {
|
||||
return Array.from(v, clone);
|
||||
}
|
||||
|
||||
if ('clone' in v && typeof v.clone === 'function') {
|
||||
return v.clone();
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(v).map(([key, value]) => [key, clone(value)]));
|
||||
};
|
||||
|
||||
export const isNumericKey = (s: string) => /^\d+$/.test(s);
|
||||
|
||||
export const isPlainObject = (x: unknown): x is Record<string, any> => !!x && getBSONType(x) === BSONType.Object;
|
||||
|
||||
export const isIndexable = (obj: unknown): obj is Record<string | number, any> => Array.isArray(obj) || isPlainObject(obj);
|
||||
|
||||
export const equals = <T>(a: T, b: T): boolean => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof a !== 'object' || typeof b !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a instanceof Date && b instanceof Date) {
|
||||
return a.valueOf() === b.valueOf();
|
||||
}
|
||||
|
||||
if (a instanceof Uint8Array && b instanceof Uint8Array) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!equals(a[i], b[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Object.keys(b).length !== Object.keys(a).length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(a)) {
|
||||
if (!(key in b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!equals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isEmptyArray = <T>(value: unknown): value is T[] & { length: 0 } => Array.isArray(value) && value.length === 0;
|
||||
|
||||
export const isTruthy = <T>(x: T | null | undefined | 0 | false | ''): x is T => Boolean(x);
|
||||
178
packages/mongo-adapter/src/filter.spec.ts
Normal file
178
packages/mongo-adapter/src/filter.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createPredicateFromFilter } from './filter';
|
||||
|
||||
describe('createPredicateFromFilter', () => {
|
||||
it('matches simple equality', () => {
|
||||
const fn = createPredicateFromFilter({ foo: 'bar' });
|
||||
expect(fn({ foo: 'bar' })).toBe(true);
|
||||
expect(fn({ foo: 'baz' })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $in', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $in: ['a', 'b'] } });
|
||||
expect(fn({ foo: 'a' })).toBe(true);
|
||||
expect(fn({ foo: 'b' })).toBe(true);
|
||||
expect(fn({ foo: 'c' })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $nin', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $nin: ['a', 'b'] } });
|
||||
expect(fn({ foo: 'a' })).toBe(false);
|
||||
expect(fn({ foo: 'c' })).toBe(true);
|
||||
expect(fn({})).toBe(true);
|
||||
});
|
||||
|
||||
it('matches $all', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $all: ['a', 'b'] } });
|
||||
expect(fn({ foo: ['a', 'b', 'c'] })).toBe(true);
|
||||
expect(fn({ foo: ['a', 'c'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $lt/$lte/$gt/$gte', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $lt: 5 } });
|
||||
expect(fn({ foo: 4 })).toBe(true);
|
||||
expect(fn({ foo: 5 })).toBe(false);
|
||||
const fn2 = createPredicateFromFilter({ foo: { $lte: 5 } });
|
||||
expect(fn2({ foo: 5 })).toBe(true);
|
||||
const fn3 = createPredicateFromFilter({ foo: { $gt: 5 } });
|
||||
expect(fn3({ foo: 6 })).toBe(true);
|
||||
const fn4 = createPredicateFromFilter({ foo: { $gte: 5 } });
|
||||
expect(fn4({ foo: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches $ne', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $ne: 1 } });
|
||||
expect(fn({ foo: 2 })).toBe(true);
|
||||
expect(fn({ foo: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $exists', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $exists: true } });
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
expect(fn({})).toBe(false);
|
||||
const fn2 = createPredicateFromFilter({ foo: { $exists: false } });
|
||||
expect(fn2({ foo: 1 })).toBe(false);
|
||||
expect(fn2({})).toBe(true);
|
||||
});
|
||||
|
||||
it('matches $mod', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $mod: [2, 0] } });
|
||||
expect(fn({ foo: 4 })).toBe(true);
|
||||
expect(fn({ foo: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $size', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $size: 2 } });
|
||||
expect(fn({ foo: [1, 2] })).toBe(true);
|
||||
expect(fn({ foo: [1] })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $type', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $type: 2 } });
|
||||
expect(fn({ foo: 'bar' })).toBe(true);
|
||||
expect(fn({ foo: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $regex', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $regex: '^b' } });
|
||||
expect(fn({ foo: 'bar' })).toBe(true);
|
||||
expect(fn({ foo: 'baz' })).toBe(true);
|
||||
expect(fn({ foo: 'car' })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $elemMatch', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $elemMatch: { bar: 1 } } });
|
||||
expect(fn({ foo: [{ bar: 1 }, { bar: 2 }] })).toBe(true);
|
||||
expect(fn({ foo: [{ bar: 2 }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $eq', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $eq: 1 } });
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
expect(fn({ foo: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $not', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $not: { $eq: 1 } } });
|
||||
expect(fn({ foo: 2 })).toBe(true);
|
||||
expect(fn({ foo: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $and', () => {
|
||||
const fn = createPredicateFromFilter({ $and: [{ foo: 1 }, { bar: 2 }] });
|
||||
expect(fn({ foo: 1, bar: 2 })).toBe(true);
|
||||
expect(fn({ foo: 1, bar: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $or', () => {
|
||||
const fn = createPredicateFromFilter({ $or: [{ foo: 1 }, { bar: 2 }] });
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
expect(fn({ bar: 2 })).toBe(true);
|
||||
expect(fn({ foo: 3, bar: 4 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $nor', () => {
|
||||
const fn = createPredicateFromFilter({ $nor: [{ foo: 1 }, { bar: 2 }] });
|
||||
expect(fn({ foo: 1 })).toBe(false);
|
||||
expect(fn({ bar: 2 })).toBe(false);
|
||||
expect(fn({ foo: 3, bar: 4 })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches $where (function)', () => {
|
||||
const fn = createPredicateFromFilter({
|
||||
$where(this: any) {
|
||||
return this.foo === 1;
|
||||
},
|
||||
});
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
expect(fn({ foo: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $where (string)', () => {
|
||||
const fn = createPredicateFromFilter({ $where: 'this.foo === 1' });
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
expect(fn({ foo: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles undefined and null', () => {
|
||||
const fn = createPredicateFromFilter({ foo: undefined, bar: null });
|
||||
expect(fn({})).toBe(true);
|
||||
expect(fn({ foo: null, bar: null })).toBe(true);
|
||||
expect(fn({ foo: 1, bar: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles nested fields', () => {
|
||||
const fn = createPredicateFromFilter({ 'foo.bar': 1 });
|
||||
expect(fn({ foo: { bar: 1 } })).toBe(true);
|
||||
expect(fn({ foo: { bar: 2 } })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles arrays for $in/$all', () => {
|
||||
const fn = createPredicateFromFilter({ foo: { $in: [1, 2] }, bar: { $all: [3, 4] } });
|
||||
expect(fn({ foo: 1, bar: [3, 4, 5] })).toBe(true);
|
||||
expect(fn({ foo: 3, bar: [3, 4, 5] })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for empty filter', () => {
|
||||
const fn = createPredicateFromFilter({});
|
||||
expect(fn({})).toBe(true);
|
||||
expect(fn({ foo: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('handles multiple keys', () => {
|
||||
const fn = createPredicateFromFilter({ foo: 1, bar: 2 });
|
||||
expect(fn({ foo: 1, bar: 2 })).toBe(true);
|
||||
expect(fn({ foo: 1, bar: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles complex nested structures', () => {
|
||||
const fn = createPredicateFromFilter({ $or: [{ a: { $exists: false } }, { b: { $eq: true } }] });
|
||||
expect(fn({ a: undefined, b: true })).toBe(true);
|
||||
expect(fn({ a: '123', b: true })).toBe(true);
|
||||
expect(fn({ a: '123', b: false })).toBe(false);
|
||||
expect(fn({ a: undefined, b: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('throws error for unsupported operators', () => {
|
||||
expect(() => createPredicateFromFilter({ $unsupported: 'value' })).toThrow('Unrecognized logical operator: $unsupported');
|
||||
});
|
||||
});
|
||||
568
packages/mongo-adapter/src/filter.ts
Normal file
568
packages/mongo-adapter/src/filter.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import type { Filter, FilterOperators } from 'mongodb';
|
||||
|
||||
import { compareBSONValues, getBSONType } from './bson';
|
||||
import { isBinary, isIndexable, isPlainObject, isTruthy, equals } from './common';
|
||||
import { createLookupFunction } from './lookups';
|
||||
import type { ArrayIndices, LookupBranch } from './types';
|
||||
|
||||
type Match = {
|
||||
readonly result: boolean;
|
||||
arrayIndices?: ArrayIndices;
|
||||
};
|
||||
|
||||
const everyMatches = <T>(arr: T[], fn: (item: T) => Match): Match =>
|
||||
arr.reduce((acc: Match, item) => (acc.result ? fn(item) : acc), { result: true });
|
||||
|
||||
const someMatches = <T>(arr: T[], fn: (item: T) => Match): Match =>
|
||||
arr.reduce((acc: Match, item) => (acc.result ? acc : fn(item)), { result: false });
|
||||
|
||||
const regexpElementMatcher =
|
||||
(regexp: RegExp) =>
|
||||
(value: unknown): boolean => {
|
||||
if (value instanceof RegExp) {
|
||||
return value.toString() === regexp.toString();
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
regexp.lastIndex = 0;
|
||||
|
||||
return regexp.test(value);
|
||||
};
|
||||
|
||||
const equalityElementMatcher = (elementSelector: unknown) => {
|
||||
if (isOperatorObject(elementSelector)) {
|
||||
throw new Error("Can't create equalityValueSelector for operator object");
|
||||
}
|
||||
|
||||
if (elementSelector === null || elementSelector === undefined) {
|
||||
return (value: unknown): boolean => value === null || value === undefined;
|
||||
}
|
||||
|
||||
return (value: unknown): boolean => equals(elementSelector, value);
|
||||
};
|
||||
|
||||
const invertBranchedMatcher =
|
||||
(branchedMatcher: (branches: LookupBranch[]) => Match) =>
|
||||
(branches: LookupBranch[]): Match => ({
|
||||
result: !branchedMatcher(branches).result,
|
||||
});
|
||||
|
||||
const getValueBitmask = (value: unknown, length: number): Uint8Array | false => {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) {
|
||||
const buffer = new ArrayBuffer(Math.max(length, 2 * Uint32Array.BYTES_PER_ELEMENT));
|
||||
|
||||
let view: Uint32Array | Uint8Array = new Uint32Array(buffer, 0, 2);
|
||||
view[0] = (value % ((1 << 16) * (1 << 16))) | 0;
|
||||
view[1] = (value / ((1 << 16) * (1 << 16))) | 0;
|
||||
|
||||
if (value < 0) {
|
||||
view = new Uint8Array(buffer, 2);
|
||||
view.forEach((_byte, i) => {
|
||||
view[i] = 0xff;
|
||||
});
|
||||
}
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
if (isBinary(value)) {
|
||||
return new Uint8Array(value.buffer);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getOperandBitmask = (operand: unknown, selector: string) => {
|
||||
if (typeof operand === 'number' && Number.isInteger(operand) && operand >= 0) {
|
||||
return new Uint8Array(new Int32Array([operand]).buffer);
|
||||
}
|
||||
|
||||
if (isBinary(operand)) {
|
||||
return new Uint8Array(operand.buffer);
|
||||
}
|
||||
|
||||
if (Array.isArray(operand) && operand.every((x) => Number.isInteger(x) && x >= 0)) {
|
||||
const buffer = new ArrayBuffer((Math.max(...operand) >> 3) + 1);
|
||||
const view = new Uint8Array(buffer);
|
||||
|
||||
operand.forEach((x) => {
|
||||
view[x >> 3] |= 1 << (x & 0x7);
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`operand to ${selector} must be a numeric bitmask (representable as a ` +
|
||||
'non-negative 32-bit signed integer), a bindata bitmask or an array with ' +
|
||||
'bit positions (non-negative integers)',
|
||||
);
|
||||
};
|
||||
|
||||
const expandArraysInBranches = (branches: LookupBranch[], skipTheArrays?: boolean) => {
|
||||
const branchesOut: LookupBranch[] = [];
|
||||
|
||||
for (const branch of branches) {
|
||||
if (!(skipTheArrays && Array.isArray(branch.value) && !branch.dontIterate)) {
|
||||
branchesOut.push({ arrayIndices: branch.arrayIndices, value: branch.value });
|
||||
}
|
||||
|
||||
if (Array.isArray(branch.value) && !branch.dontIterate) {
|
||||
branch.value.forEach((value: unknown, i) => {
|
||||
branchesOut.push({
|
||||
arrayIndices: (branch.arrayIndices || []).concat(i),
|
||||
value,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return branchesOut;
|
||||
};
|
||||
|
||||
const convertElementMatcherToBranchedMatcher = (
|
||||
elementMatcher: (value: unknown) => boolean | number,
|
||||
options: { dontExpandLeafArrays?: boolean; dontIncludeLeafArrays?: boolean } = {},
|
||||
) => {
|
||||
return (branches: LookupBranch[]) => {
|
||||
const expanded = options.dontExpandLeafArrays ? branches : expandArraysInBranches(branches, options.dontIncludeLeafArrays);
|
||||
|
||||
return someMatches(expanded, (element) => {
|
||||
const matched = elementMatcher(element.value);
|
||||
|
||||
if (typeof matched === 'number') {
|
||||
return {
|
||||
result: true,
|
||||
arrayIndices: [matched],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: matched,
|
||||
arrayIndices: element.arrayIndices,
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const operatorBranchedMatcher = <T>(valueSelector: FilterOperators<T>) => {
|
||||
const operatorMatchers = Object.entries(valueSelector).map(([operator, operand]): ((branches: LookupBranch[]) => Match) => {
|
||||
if (isValueOperator(operator)) {
|
||||
return valueOperators[operator](operand, valueSelector);
|
||||
}
|
||||
|
||||
if (isElementOperator(operator)) {
|
||||
const compileElementSelector = elementOperators[operator];
|
||||
return convertElementMatcherToBranchedMatcher(compileElementSelector(operand, valueSelector), {
|
||||
dontExpandLeafArrays: operator === '$size' || operator === '$elemMatch',
|
||||
dontIncludeLeafArrays: operator === '$type',
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unrecognized operator: ${operator}`);
|
||||
});
|
||||
|
||||
return (branches: LookupBranch[]) => everyMatches(operatorMatchers, (fn) => fn(branches));
|
||||
};
|
||||
|
||||
const $in = (operand: unknown): ((value: unknown) => boolean) => {
|
||||
if (!Array.isArray(operand)) {
|
||||
throw new Error('$in needs an array');
|
||||
}
|
||||
|
||||
const elementMatchers = operand.map((option) => {
|
||||
if (option instanceof RegExp) {
|
||||
return regexpElementMatcher(option);
|
||||
}
|
||||
|
||||
if (isOperatorObject(option)) {
|
||||
throw new Error('cannot nest $ under $in');
|
||||
}
|
||||
|
||||
return equalityElementMatcher(option);
|
||||
});
|
||||
|
||||
return (value: unknown) => {
|
||||
if (value === undefined) {
|
||||
value = null;
|
||||
}
|
||||
|
||||
return elementMatchers.some((matcher) => matcher(value));
|
||||
};
|
||||
};
|
||||
|
||||
const $eq = (operand: unknown) => convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand));
|
||||
|
||||
const $not = (operand: unknown) => {
|
||||
return invertBranchedMatcher(compileValueSelector(operand));
|
||||
};
|
||||
|
||||
const $ne = (operand: unknown) => invertBranchedMatcher(convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand)));
|
||||
|
||||
const $nin = (operand: unknown) => invertBranchedMatcher(convertElementMatcherToBranchedMatcher($in(operand)));
|
||||
|
||||
const $exists = (operand: unknown) => {
|
||||
const exists = convertElementMatcherToBranchedMatcher((value) => value !== undefined);
|
||||
return operand ? exists : invertBranchedMatcher(exists);
|
||||
};
|
||||
|
||||
const $options = <T>(_operand: unknown, valueSelector: FilterOperators<T>) => {
|
||||
if (!('$regex' in valueSelector)) {
|
||||
throw new Error('$options needs a $regex');
|
||||
}
|
||||
|
||||
return () => ({ result: true });
|
||||
};
|
||||
|
||||
const $all = (operand: unknown) => {
|
||||
if (!Array.isArray(operand)) {
|
||||
throw new Error('$all requires array');
|
||||
}
|
||||
|
||||
if (operand.length === 0) {
|
||||
return () => ({ result: false });
|
||||
}
|
||||
|
||||
const branchedMatchers = operand.map((criterion) => {
|
||||
if (isOperatorObject(criterion)) {
|
||||
throw new Error('no $ expressions in $all');
|
||||
}
|
||||
|
||||
return compileValueSelector(criterion);
|
||||
});
|
||||
|
||||
return (branches: LookupBranch[]) => everyMatches(branchedMatchers, (fn) => fn(branches));
|
||||
};
|
||||
|
||||
const valueOperators = {
|
||||
$eq,
|
||||
$not,
|
||||
$ne,
|
||||
$nin,
|
||||
$exists,
|
||||
$options,
|
||||
$all,
|
||||
} as const;
|
||||
|
||||
const isValueOperator = (operator: string): operator is keyof typeof valueOperators => operator in valueOperators;
|
||||
|
||||
function createInequalityOperator(selector: (compValue: number) => boolean) {
|
||||
return (operand: unknown) => {
|
||||
if (Array.isArray(operand)) {
|
||||
return () => false;
|
||||
}
|
||||
|
||||
operand ??= null;
|
||||
|
||||
const operandType = getBSONType(operand);
|
||||
|
||||
return (value: unknown) => {
|
||||
value ??= null;
|
||||
|
||||
if (getBSONType(value) !== operandType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selector(compareBSONValues(value, operand));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const $lt = createInequalityOperator((cmpValue: number) => cmpValue < 0);
|
||||
|
||||
const $gt = createInequalityOperator((cmpValue: number) => cmpValue > 0);
|
||||
|
||||
const $lte = createInequalityOperator((cmpValue: number) => cmpValue <= 0);
|
||||
|
||||
const $gte = createInequalityOperator((cmpValue: number) => cmpValue >= 0);
|
||||
|
||||
const $mod = (operand: unknown) => {
|
||||
if (!(Array.isArray(operand) && operand.length === 2 && typeof operand[0] === 'number' && typeof operand[1] === 'number')) {
|
||||
throw new Error('argument to $mod must be an array of two numbers');
|
||||
}
|
||||
|
||||
const [divisor, remainder] = operand;
|
||||
return (value: unknown) => typeof value === 'number' && value % divisor === remainder;
|
||||
};
|
||||
|
||||
const $size = (operand: unknown) => {
|
||||
if (typeof operand === 'string') {
|
||||
operand = 0;
|
||||
} else if (typeof operand !== 'number') {
|
||||
throw new Error('$size needs a number');
|
||||
}
|
||||
|
||||
return (value: unknown) => Array.isArray(value) && value.length === operand;
|
||||
};
|
||||
|
||||
const $type = (operand: unknown) => {
|
||||
if (typeof operand === 'string') {
|
||||
const operandAliasMap = {
|
||||
double: 1,
|
||||
string: 2,
|
||||
object: 3,
|
||||
array: 4,
|
||||
binData: 5,
|
||||
undefined: 6,
|
||||
objectId: 7,
|
||||
bool: 8,
|
||||
date: 9,
|
||||
null: 10,
|
||||
regex: 11,
|
||||
dbPointer: 12,
|
||||
javascript: 13,
|
||||
symbol: 14,
|
||||
javascriptWithScope: 15,
|
||||
int: 16,
|
||||
timestamp: 17,
|
||||
long: 18,
|
||||
decimal: 19,
|
||||
minKey: -1,
|
||||
maxKey: 127,
|
||||
};
|
||||
if (!(operand in operandAliasMap)) {
|
||||
throw new Error(`unknown string alias for $type: ${operand}`);
|
||||
}
|
||||
operand = operandAliasMap[operand as keyof typeof operandAliasMap];
|
||||
} else if (typeof operand === 'number') {
|
||||
if (operand === 0 || operand < -1 || (operand > 19 && operand !== 127)) {
|
||||
throw new Error(`Invalid numerical $type code: ${operand}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('argument to $type is not a number or a string');
|
||||
}
|
||||
|
||||
return (value: unknown) => value !== undefined && getBSONType(value) === operand;
|
||||
};
|
||||
|
||||
const $bitsAllSet = (operand: unknown) => {
|
||||
const mask = getOperandBitmask(operand, '$bitsAllSet');
|
||||
return (value: unknown) => {
|
||||
const bitmask = getValueBitmask(value, mask.length);
|
||||
return bitmask && mask.every((byte, i) => (bitmask[i] & byte) === byte);
|
||||
};
|
||||
};
|
||||
|
||||
const $bitsAnySet = (operand: unknown) => {
|
||||
const mask = getOperandBitmask(operand, '$bitsAnySet');
|
||||
return (value: unknown) => {
|
||||
const bitmask = getValueBitmask(value, mask.length);
|
||||
return bitmask && mask.some((byte, i) => (~bitmask[i] & byte) !== byte);
|
||||
};
|
||||
};
|
||||
|
||||
const $bitsAllClear = (operand: unknown) => {
|
||||
const mask = getOperandBitmask(operand, '$bitsAllClear');
|
||||
return (value: unknown) => {
|
||||
const bitmask = getValueBitmask(value, mask.length);
|
||||
return bitmask && mask.every((byte, i) => !(bitmask[i] & byte));
|
||||
};
|
||||
};
|
||||
|
||||
const $bitsAnyClear = (operand: unknown) => {
|
||||
const mask = getOperandBitmask(operand, '$bitsAnyClear');
|
||||
return (value: unknown) => {
|
||||
const bitmask = getValueBitmask(value, mask.length);
|
||||
return bitmask && mask.some((byte, i) => (bitmask[i] & byte) !== byte);
|
||||
};
|
||||
};
|
||||
|
||||
const $regex = <T>(operand: unknown, valueSelector: FilterOperators<T>) => {
|
||||
if (!(typeof operand === 'string' || operand instanceof RegExp)) {
|
||||
throw new Error('$regex has to be a string or RegExp');
|
||||
}
|
||||
|
||||
if (valueSelector.$options !== undefined) {
|
||||
if (/[^gim]/.test(valueSelector.$options)) {
|
||||
throw new Error('Only the i, m, and g regexp options are supported');
|
||||
}
|
||||
|
||||
const source = operand instanceof RegExp ? operand.source : operand;
|
||||
return regexpElementMatcher(new RegExp(source, valueSelector.$options));
|
||||
}
|
||||
|
||||
if (operand instanceof RegExp) {
|
||||
return regexpElementMatcher(operand);
|
||||
}
|
||||
|
||||
return regexpElementMatcher(new RegExp(operand));
|
||||
};
|
||||
|
||||
const $elemMatch = <T>(operand: unknown) => {
|
||||
if (!isPlainObject(operand)) {
|
||||
throw new Error('$elemMatch need an object');
|
||||
}
|
||||
|
||||
const isDocMatcher = !isOperatorObject(
|
||||
Object.entries(operand)
|
||||
.filter(([key]) => !isLogicalOperator(key))
|
||||
.reduce((a, [key, value]) => Object.assign(a, { [key]: value }), {}),
|
||||
true,
|
||||
);
|
||||
|
||||
if (isDocMatcher) {
|
||||
const subMatcher = createDocumentMatcherFromFilter(operand);
|
||||
|
||||
return (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; ++i) {
|
||||
const arrayElement = value[i];
|
||||
if (!isIndexable(arrayElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subMatcher(arrayElement as T).result) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const subMatcher = compileValueSelector(operand);
|
||||
|
||||
return (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; ++i) {
|
||||
const arrayElement = value[i];
|
||||
if (subMatcher([{ value: arrayElement, dontIterate: true }]).result) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
const elementOperators = {
|
||||
$lt,
|
||||
$gt,
|
||||
$lte,
|
||||
$gte,
|
||||
$mod,
|
||||
$in,
|
||||
$size,
|
||||
$type,
|
||||
$bitsAllSet,
|
||||
$bitsAnySet,
|
||||
$bitsAllClear,
|
||||
$bitsAnyClear,
|
||||
$regex,
|
||||
$elemMatch,
|
||||
} as const;
|
||||
|
||||
const isElementOperator = (operator: string): operator is keyof typeof elementOperators => operator in elementOperators;
|
||||
|
||||
const $and = <T>(subSelector: Filter<T>[]) => {
|
||||
const matchers = compileArrayOfDocumentSelectors(subSelector);
|
||||
return (doc: T) => everyMatches(matchers, (fn) => fn(doc));
|
||||
};
|
||||
|
||||
const $or = <T>(subSelector: Filter<T>[]) => {
|
||||
const matchers = compileArrayOfDocumentSelectors(subSelector);
|
||||
return (doc: T) => ({ result: matchers.some((fn) => fn(doc).result) });
|
||||
};
|
||||
|
||||
const $nor = <T>(subSelector: Filter<T>[]) => {
|
||||
const matchers = compileArrayOfDocumentSelectors(subSelector);
|
||||
return (doc: T) => ({ result: matchers.every((fn) => !fn(doc).result) });
|
||||
};
|
||||
|
||||
const $where = <T>(selectorValue: string | ((this: T, doc: T) => boolean)) => {
|
||||
if (!(selectorValue instanceof Function)) {
|
||||
selectorValue = Function('obj', `return ${selectorValue}`) as (this: T, doc: T) => boolean;
|
||||
}
|
||||
|
||||
return (doc: T) => ({ result: selectorValue.call(doc, doc) });
|
||||
};
|
||||
|
||||
const logicalOperators = {
|
||||
$and,
|
||||
$or,
|
||||
$nor,
|
||||
$where,
|
||||
} as const;
|
||||
|
||||
const isLogicalOperator = (operator: string): operator is keyof typeof logicalOperators => operator in logicalOperators;
|
||||
|
||||
const isOperatorObject = <TOperator extends `$${string}`>(
|
||||
valueSelector: unknown,
|
||||
inconsistentOK = false,
|
||||
): valueSelector is Record<TOperator, any> => {
|
||||
if (!isPlainObject(valueSelector)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let theseAreOperators: boolean | undefined = undefined;
|
||||
for (const selKey of Object.keys(valueSelector)) {
|
||||
const thisIsOperator = selKey.slice(0, 1) === '$' || selKey === 'diff';
|
||||
|
||||
if (theseAreOperators === undefined) {
|
||||
theseAreOperators = thisIsOperator;
|
||||
} else if (theseAreOperators !== thisIsOperator) {
|
||||
if (!inconsistentOK) {
|
||||
throw new Error(`Inconsistent operator: ${JSON.stringify(valueSelector)}`);
|
||||
}
|
||||
|
||||
theseAreOperators = false;
|
||||
}
|
||||
}
|
||||
|
||||
return theseAreOperators ?? true;
|
||||
};
|
||||
|
||||
const compileValueSelector = (valueSelector: unknown) => {
|
||||
if (valueSelector instanceof RegExp) {
|
||||
return convertElementMatcherToBranchedMatcher(regexpElementMatcher(valueSelector));
|
||||
}
|
||||
|
||||
if (isOperatorObject(valueSelector)) {
|
||||
return operatorBranchedMatcher(valueSelector);
|
||||
}
|
||||
|
||||
return convertElementMatcherToBranchedMatcher(equalityElementMatcher(valueSelector));
|
||||
};
|
||||
|
||||
const compileArrayOfDocumentSelectors = <T>(selectors: Filter<T>[]) => {
|
||||
return selectors.map((subSelector) => createDocumentMatcherFromFilter(subSelector));
|
||||
};
|
||||
|
||||
export const createDocumentMatcherFromFilter = <T>(filter: Filter<T>) => {
|
||||
const docMatchers = Object.entries(filter)
|
||||
.map(([key, subSelector]): ((doc: T) => Match) | undefined => {
|
||||
if (isLogicalOperator(key)) {
|
||||
return logicalOperators[key](subSelector);
|
||||
}
|
||||
|
||||
if (key.slice(0, 1) === '$') {
|
||||
throw new Error(`Unrecognized logical operator: ${key}`);
|
||||
}
|
||||
|
||||
if (typeof subSelector === 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lookUpByIndex = createLookupFunction(key);
|
||||
const valueMatcher = compileValueSelector(subSelector);
|
||||
|
||||
return (doc: T) => valueMatcher(lookUpByIndex(doc));
|
||||
})
|
||||
.filter(isTruthy);
|
||||
|
||||
return (doc: T) => everyMatches(docMatchers, (fn) => fn(doc));
|
||||
};
|
||||
|
||||
export const createPredicateFromFilter = <T>(filter: Filter<T>): ((doc: T) => boolean) => {
|
||||
const docMatcher = createDocumentMatcherFromFilter(filter);
|
||||
return (doc: T) => docMatcher(doc).result;
|
||||
};
|
||||
5
packages/mongo-adapter/src/index.ts
Normal file
5
packages/mongo-adapter/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { ArrayIndices } from './types';
|
||||
export { getBSONType } from './bson';
|
||||
export { createPredicateFromFilter, createDocumentMatcherFromFilter } from './filter';
|
||||
export { createComparatorFromSort } from './sort';
|
||||
export { createTransformFromUpdateFilter, createUpsertDocument } from './updateFilter';
|
||||
58
packages/mongo-adapter/src/lookups.spec.ts
Normal file
58
packages/mongo-adapter/src/lookups.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createLookupFunction } from './lookups';
|
||||
|
||||
describe('createLookupFunction', () => {
|
||||
it('should lookup `a`', () => {
|
||||
const lookupA = createLookupFunction('a');
|
||||
expect(lookupA({})).toStrictEqual([{ value: undefined }]);
|
||||
expect(lookupA({ a: 1 })).toStrictEqual([{ value: 1 }]);
|
||||
expect(lookupA({ a: [1] })).toStrictEqual([{ value: [1] }]);
|
||||
});
|
||||
|
||||
it('should lookup `a.x`', () => {
|
||||
const lookupAX = createLookupFunction('a.x');
|
||||
expect(lookupAX({ a: { x: 1 } })).toStrictEqual([{ value: 1 }]);
|
||||
expect(lookupAX({ a: { x: [1] } })).toStrictEqual([{ value: [1] }]);
|
||||
expect(lookupAX({ a: 5 })).toStrictEqual([{ value: undefined }]);
|
||||
expect(lookupAX({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).toStrictEqual([
|
||||
{ value: 1, arrayIndices: [0] },
|
||||
{ value: [2], arrayIndices: [1] },
|
||||
{ value: undefined, arrayIndices: [2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should lookup `a.0.x`', () => {
|
||||
const lookupA0X = createLookupFunction('a.0.x');
|
||||
expect(lookupA0X({ a: [{ x: 1 }] })).toStrictEqual([
|
||||
{ value: 1, arrayIndices: [0, 'x'] },
|
||||
{ value: undefined, arrayIndices: [0] },
|
||||
]);
|
||||
expect(lookupA0X({ a: [{ x: [1] }] })).toStrictEqual([
|
||||
{ value: [1], arrayIndices: [0, 'x'] },
|
||||
{ value: undefined, arrayIndices: [0] },
|
||||
]);
|
||||
expect(lookupA0X({ a: 5 })).toStrictEqual([{ value: undefined }]);
|
||||
expect(lookupA0X({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).toStrictEqual([
|
||||
{ value: 1, arrayIndices: [0, 'x'] },
|
||||
{ value: undefined, arrayIndices: [0] },
|
||||
{ value: undefined, arrayIndices: [1] },
|
||||
{ value: undefined, arrayIndices: [2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should lookup w.x.0.z`', () => {
|
||||
const lookupWX0Z = createLookupFunction('w.x.0.z');
|
||||
|
||||
expect(lookupWX0Z({ w: [{ x: [{ z: 5 }] }] })).toStrictEqual([
|
||||
{ value: 5, arrayIndices: [0, 0, 'x'] },
|
||||
{ value: undefined, arrayIndices: [0, 0] },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('with options.forSort', () => {
|
||||
it('should lookup `w.x.0.z` for sort', () => {
|
||||
const lookupWX0ZForSort = createLookupFunction('w.x.0.z', { forSort: true });
|
||||
|
||||
expect(lookupWX0ZForSort({ w: [{ x: [{ z: 5 }] }] })).toStrictEqual([{ value: 5, arrayIndices: [0, 0, 'x'] }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
packages/mongo-adapter/src/lookups.ts
Normal file
59
packages/mongo-adapter/src/lookups.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { isIndexable, isNumericKey, isPlainObject } from './common';
|
||||
import type { ArrayIndices, LookupBranch } from './types';
|
||||
|
||||
const buildResult = (arrayIndices: ArrayIndices | undefined, dontIterate: boolean, value: unknown): [LookupBranch] => {
|
||||
if (arrayIndices?.length) {
|
||||
if (dontIterate) {
|
||||
return [{ arrayIndices, dontIterate, value }];
|
||||
}
|
||||
return [{ arrayIndices, value }];
|
||||
}
|
||||
if (dontIterate) {
|
||||
return [{ dontIterate, value }];
|
||||
}
|
||||
return [{ value }];
|
||||
};
|
||||
|
||||
export const createLookupFunction = (
|
||||
key: string,
|
||||
options: { forSort?: boolean } = {},
|
||||
): (<T>(doc: T, arrayIndices?: ArrayIndices) => LookupBranch[]) => {
|
||||
const [firstPart = '', ...rest] = key.split('.');
|
||||
const lookupRest = rest.length > 0 ? createLookupFunction(rest.join('.'), options) : undefined;
|
||||
|
||||
return <T>(doc: T, arrayIndices?: ArrayIndices): LookupBranch[] => {
|
||||
if (Array.isArray(doc)) {
|
||||
if (!(isNumericKey(firstPart) && +firstPart < doc.length)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
arrayIndices = arrayIndices ? arrayIndices.concat(+firstPart, 'x') : [+firstPart, 'x'];
|
||||
}
|
||||
|
||||
const firstLevel = doc[firstPart as keyof typeof doc];
|
||||
|
||||
if (!lookupRest) {
|
||||
return buildResult(arrayIndices, Array.isArray(doc) && Array.isArray(firstLevel), firstLevel);
|
||||
}
|
||||
|
||||
if (!isIndexable(firstLevel)) {
|
||||
if (Array.isArray(doc)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return buildResult(arrayIndices, false, undefined);
|
||||
}
|
||||
|
||||
const result: LookupBranch[] = lookupRest(firstLevel, arrayIndices);
|
||||
|
||||
if (Array.isArray(firstLevel) && !(isNumericKey(rest[0]) && options.forSort)) {
|
||||
firstLevel.forEach((branch, arrayIndex) => {
|
||||
if (isPlainObject(branch)) {
|
||||
result.push(...lookupRest(branch, arrayIndices ? [...arrayIndices, arrayIndex] : [arrayIndex]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
210
packages/mongo-adapter/src/sort.spec.ts
Normal file
210
packages/mongo-adapter/src/sort.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createComparatorFromSort } from './sort';
|
||||
|
||||
describe('createComparatorFromSort', () => {
|
||||
describe('object specification format', () => {
|
||||
it('should sort by a single field ascending', () => {
|
||||
const sorter = createComparatorFromSort({ name: 1 });
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0);
|
||||
expect(sorter(b, a)).toBeGreaterThan(0);
|
||||
expect(sorter(a, a)).toBe(0);
|
||||
});
|
||||
|
||||
it('should sort by a single field descending', () => {
|
||||
const sorter = createComparatorFromSort({ name: -1 });
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0);
|
||||
expect(sorter(b, a)).toBeLessThan(0);
|
||||
expect(sorter(a, a)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple fields with different directions', () => {
|
||||
const sorter = createComparatorFromSort({ age: 1, name: -1 });
|
||||
|
||||
const a = { age: 30, name: 'Alice' };
|
||||
const b = { age: 30, name: 'Bob' };
|
||||
const c = { age: 25, name: 'Charlie' };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0); // Same age, name is used (desc)
|
||||
expect(sorter(a, c)).toBeGreaterThan(0); // Different age
|
||||
expect(sorter(c, b)).toBeLessThan(0); // Different age
|
||||
});
|
||||
|
||||
it('should handle when the first field has equal values', () => {
|
||||
const sorter = createComparatorFromSort({ age: 1, name: 1 });
|
||||
|
||||
const a = { age: 30, name: 'Alice' };
|
||||
const b = { age: 30, name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0); // Same age, name is used (asc)
|
||||
});
|
||||
|
||||
it('should handle missing fields', () => {
|
||||
const sorter = createComparatorFromSort({ age: 1 });
|
||||
|
||||
const a = { age: 30 };
|
||||
const b = {}; // Missing age
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0); // undefined is considered smaller
|
||||
expect(sorter(b, a)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should sort numbers correctly', () => {
|
||||
const sorter = createComparatorFromSort({ score: 1 });
|
||||
|
||||
const a = { score: 100 };
|
||||
const b = { score: 50 };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0);
|
||||
expect(sorter(b, a)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle nested fields', () => {
|
||||
const sorter = createComparatorFromSort({ 'user.age': 1 });
|
||||
|
||||
const a = { user: { age: 30 } };
|
||||
const b = { user: { age: 25 } };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0);
|
||||
expect(sorter(b, a)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array specification format', () => {
|
||||
it('should sort using string array format', () => {
|
||||
const sorter = createComparatorFromSort(['name']);
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0);
|
||||
expect(sorter(b, a)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should sort using field and direction array format', () => {
|
||||
const sorter = createComparatorFromSort([['name', 'desc']]);
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0);
|
||||
expect(sorter(b, a)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle mix of string and array formats', () => {
|
||||
// @ts-expect-error - mixing string and array formats is allowed
|
||||
const sorter = createComparatorFromSort(['age', ['name', 'desc']]);
|
||||
|
||||
const a = { age: 30, name: 'Alice' };
|
||||
const b = { age: 30, name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle ascending in array format', () => {
|
||||
const sorter = createComparatorFromSort([['name', 'asc']]);
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array values handling', () => {
|
||||
it('should handle array values by selecting min value for ascending', () => {
|
||||
const sorter = createComparatorFromSort({ scores: 1 });
|
||||
|
||||
const a = { scores: [10, 20, 30] };
|
||||
const b = { scores: [5, 15, 25] };
|
||||
|
||||
expect(sorter(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle array values by selecting max value for descending', () => {
|
||||
const sorter = createComparatorFromSort({ scores: -1 });
|
||||
|
||||
const a = { scores: [10, 20, 30] };
|
||||
const b = { scores: [5, 15, 25] };
|
||||
|
||||
expect(sorter(a, b)).toBe(-5);
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const sorter = createComparatorFromSort({ scores: 1 });
|
||||
|
||||
const a = { scores: [] };
|
||||
const b = { scores: [5] };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0); // undefined vs 5
|
||||
});
|
||||
|
||||
it('should handle nested arrays', () => {
|
||||
const sorter = createComparatorFromSort({ 'users.scores': 1 });
|
||||
|
||||
const a = { users: [{ scores: [10, 5] }, { scores: [20, 15] }] };
|
||||
const b = { users: [{ scores: [8, 3] }, { scores: [18, 13] }] };
|
||||
|
||||
expect(sorter(a, b)).toBeGreaterThan(0); // Should compare minimums
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty sort specification', () => {
|
||||
const sorter = createComparatorFromSort({});
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBe(0); // No sort criteria so all docs equal
|
||||
});
|
||||
|
||||
it('should handle empty array sort specification', () => {
|
||||
const sorter = createComparatorFromSort([]);
|
||||
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
|
||||
expect(sorter(a, b)).toBe(0); // No sort criteria so all docs equal
|
||||
});
|
||||
|
||||
it('should sort by multiple fields when first fields are equal', () => {
|
||||
const sorter = createComparatorFromSort({ lastName: 1, firstName: 1 });
|
||||
|
||||
const a = { lastName: 'Smith', firstName: 'Alice' };
|
||||
const b = { lastName: 'Smith', firstName: 'Bob' };
|
||||
const c = { lastName: 'Jones', firstName: 'Charlie' };
|
||||
|
||||
expect(sorter(a, b)).toBeLessThan(0); // Same last name, different first name
|
||||
expect(sorter(a, c)).toBeGreaterThan(0); // Different last names
|
||||
});
|
||||
|
||||
it('should handle objects and scalars in the same comparison', () => {
|
||||
const sorter = createComparatorFromSort({ data: 1 });
|
||||
|
||||
const a = { data: { value: 10 } };
|
||||
const b = { data: 5 };
|
||||
|
||||
// This tests that the BSON comparison works correctly for different types
|
||||
expect(typeof sorter(a, b)).toBe('number'); // Should return a number without error
|
||||
});
|
||||
|
||||
it('should handle null and undefined values', () => {
|
||||
const sorter = createComparatorFromSort({ value: 1 });
|
||||
|
||||
const a = { value: null };
|
||||
const b = {};
|
||||
const c = { value: 0 };
|
||||
|
||||
expect(sorter(a, c)).toBeLessThan(0); // null is less than 0
|
||||
expect(sorter(b, c)).toBeLessThan(0); // undefined is less than 0
|
||||
expect(sorter(a, b)).toBeGreaterThan(0); // undefined is less than null
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/mongo-adapter/src/sort.ts
Normal file
87
packages/mongo-adapter/src/sort.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Sort } from 'mongodb';
|
||||
|
||||
import { compareBSONValues } from './bson';
|
||||
import { isEmptyArray } from './common';
|
||||
import { createLookupFunction } from './lookups';
|
||||
import type { LookupBranch } from './types';
|
||||
|
||||
const createSortSpecParts = <T>(
|
||||
spec: Sort,
|
||||
): {
|
||||
lookup: (doc: T) => LookupBranch[];
|
||||
ascending: boolean;
|
||||
}[] => {
|
||||
if (Array.isArray(spec)) {
|
||||
return spec.map((value) => {
|
||||
if (typeof value === 'string') {
|
||||
return {
|
||||
lookup: createLookupFunction(value, { forSort: true }),
|
||||
ascending: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
lookup: createLookupFunction(value[0], { forSort: true }),
|
||||
ascending: value[1] !== 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && '$meta' in value) {
|
||||
throw new Error('MongoDB $meta sort is not supported in the adapter');
|
||||
}
|
||||
|
||||
throw new Error('MongoDB numeric sort is not supported in the adapter');
|
||||
});
|
||||
}
|
||||
|
||||
return Object.entries(spec).map(([key, value]) => ({
|
||||
lookup: createLookupFunction(key, { forSort: true }),
|
||||
ascending: value >= 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const reduceValue = (branchValues: LookupBranch[], ascending: boolean): unknown =>
|
||||
branchValues
|
||||
.flatMap(({ value }) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (isEmptyArray(value)) {
|
||||
return [undefined];
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
.reduce((reduced, value) => {
|
||||
const cmp = compareBSONValues(reduced, value);
|
||||
if ((ascending && cmp > 0) || (!ascending && cmp < 0)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return reduced;
|
||||
});
|
||||
|
||||
export const createComparatorFromSort = (spec: Sort): ((a: unknown, b: unknown) => number) => {
|
||||
const sortSpecParts = createSortSpecParts(spec);
|
||||
|
||||
if (sortSpecParts.length === 0) {
|
||||
return () => 0;
|
||||
}
|
||||
|
||||
return (a, b) => {
|
||||
for (let i = 0; i < sortSpecParts.length; ++i) {
|
||||
const { lookup, ascending } = sortSpecParts[i];
|
||||
const aValue = reduceValue(lookup(a), ascending);
|
||||
const bValue = reduceValue(lookup(b), ascending);
|
||||
const compare = compareBSONValues(aValue, bValue);
|
||||
|
||||
if (compare !== 0) {
|
||||
return ascending ? compare : -compare;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
};
|
||||
34
packages/mongo-adapter/src/types.ts
Normal file
34
packages/mongo-adapter/src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const enum BSONType {
|
||||
Double = 1,
|
||||
String,
|
||||
Object,
|
||||
Array,
|
||||
BinData,
|
||||
/** @deprecated */
|
||||
Undefined,
|
||||
ObjectId,
|
||||
Boolean,
|
||||
Date,
|
||||
Null,
|
||||
Regex,
|
||||
/** @deprecated */
|
||||
DBPointer,
|
||||
JavaScript,
|
||||
/** @deprecated */
|
||||
Symbol,
|
||||
JavaScriptWithScope,
|
||||
Int,
|
||||
Timestamp,
|
||||
Long,
|
||||
Decimal,
|
||||
MinKey = -1,
|
||||
MaxKey = 127,
|
||||
}
|
||||
|
||||
export type ArrayIndices = (number | 'x')[];
|
||||
|
||||
export type LookupBranch = {
|
||||
value: unknown;
|
||||
dontIterate?: boolean;
|
||||
arrayIndices?: ArrayIndices;
|
||||
};
|
||||
1344
packages/mongo-adapter/src/updateFilter.spec.ts
Normal file
1344
packages/mongo-adapter/src/updateFilter.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
674
packages/mongo-adapter/src/updateFilter.ts
Normal file
674
packages/mongo-adapter/src/updateFilter.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
import type { Document, Filter, FilterOperators, Sort, UpdateFilter } from 'mongodb';
|
||||
|
||||
import { getBSONType } from './bson';
|
||||
import { assertHasValidFieldNames, assertIsValidFieldName, clone, isIndexable, isNumericKey, isPlainObject, equals } from './common';
|
||||
import { createPredicateFromFilter } from './filter';
|
||||
import { createComparatorFromSort } from './sort';
|
||||
import { BSONType, type ArrayIndices } from './types';
|
||||
|
||||
const isUpdateModifiers = <T>(mod: UpdateFilter<T>): boolean => {
|
||||
let isModify = false;
|
||||
let isReplace = false;
|
||||
|
||||
for (const key of Object.keys(mod)) {
|
||||
if (key.slice(0, 1) === '$') {
|
||||
isModify = true;
|
||||
} else {
|
||||
isReplace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModify && isReplace) {
|
||||
throw new Error('Update parameter cannot have both modifier and non-modifier fields.');
|
||||
}
|
||||
|
||||
return isModify;
|
||||
};
|
||||
|
||||
const $currentDate = <TField extends string>(target: Record<TField, Date>, field: TField, arg: { $type: 'date' } | true) => {
|
||||
if (typeof arg === 'object' && '$type' in arg) {
|
||||
if (arg.$type !== 'date') {
|
||||
throw new Error('Minimongo does currently only support the date type in $currentDate modifiers');
|
||||
}
|
||||
} else if (arg !== true) {
|
||||
throw new Error('Invalid $currentDate modifier');
|
||||
}
|
||||
|
||||
target[field] = new Date();
|
||||
};
|
||||
|
||||
const $inc = <TField extends string>(target: Record<TField, number>, field: TField, arg: number) => {
|
||||
if (typeof arg !== 'number') {
|
||||
throw new Error('Modifier $inc allowed for numbers only');
|
||||
}
|
||||
|
||||
if (field in target) {
|
||||
if (typeof target[field] !== 'number') {
|
||||
throw new Error('Cannot apply $inc modifier to non-number');
|
||||
}
|
||||
|
||||
target[field] += arg;
|
||||
} else {
|
||||
target[field] = arg;
|
||||
}
|
||||
};
|
||||
|
||||
const $min = <TField extends string>(target: Record<TField, number>, field: TField, arg: number) => {
|
||||
if (typeof arg !== 'number') {
|
||||
throw new Error('Modifier $min allowed for numbers only');
|
||||
}
|
||||
|
||||
if (field in target) {
|
||||
if (typeof target[field] !== 'number') {
|
||||
throw new Error('Cannot apply $min modifier to non-number');
|
||||
}
|
||||
|
||||
if (target[field] > arg) {
|
||||
target[field] = arg;
|
||||
}
|
||||
} else {
|
||||
target[field] = arg;
|
||||
}
|
||||
};
|
||||
|
||||
const $max = <TField extends string>(target: Record<TField, number>, field: TField, arg: number) => {
|
||||
if (typeof arg !== 'number') {
|
||||
throw new Error('Modifier $max allowed for numbers only');
|
||||
}
|
||||
|
||||
if (field in target) {
|
||||
if (typeof target[field] !== 'number') {
|
||||
throw new Error('Cannot apply $max modifier to non-number');
|
||||
}
|
||||
|
||||
if (target[field] < arg) {
|
||||
target[field] = arg;
|
||||
}
|
||||
} else {
|
||||
target[field] = arg;
|
||||
}
|
||||
};
|
||||
|
||||
const $mul = <TField extends string>(target: Record<TField, number>, field: TField, arg: number) => {
|
||||
if (typeof arg !== 'number') {
|
||||
throw new Error('Modifier $mul allowed for numbers only');
|
||||
}
|
||||
|
||||
if (field in target) {
|
||||
if (typeof target[field] !== 'number') {
|
||||
throw new Error('Cannot apply $mul modifier to non-number');
|
||||
}
|
||||
|
||||
target[field] *= arg;
|
||||
} else {
|
||||
target[field] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const $rename = <TField extends string>(
|
||||
target: Record<TField, object>,
|
||||
field: TField,
|
||||
arg: string,
|
||||
keypath: string,
|
||||
doc: Record<string, any>,
|
||||
) => {
|
||||
if (keypath === arg) {
|
||||
throw new Error('$rename source must differ from target');
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
throw new Error('$rename source field invalid');
|
||||
}
|
||||
|
||||
if (typeof arg !== 'string') {
|
||||
throw new Error('$rename target must be a string');
|
||||
}
|
||||
|
||||
if (arg.includes('\0')) {
|
||||
throw new Error("The 'to' field for $rename cannot contain an embedded null byte");
|
||||
}
|
||||
|
||||
if (target === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = target[field];
|
||||
|
||||
delete target[field];
|
||||
|
||||
const keyparts = arg.split('.');
|
||||
const target2 = findModTarget(doc, keyparts, { forbidArray: true });
|
||||
|
||||
if (!target2) {
|
||||
throw new Error('$rename target field invalid');
|
||||
}
|
||||
|
||||
target2[keyparts.pop() as keyof typeof target2] = object;
|
||||
};
|
||||
|
||||
const $set = <TField extends string>(target: Record<TField, object>, field: TField, arg: object) => {
|
||||
if (target !== Object(target)) {
|
||||
throw new Error('Cannot set property on non-object field');
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
throw new Error('Cannot set property on null');
|
||||
}
|
||||
|
||||
assertHasValidFieldNames(arg);
|
||||
|
||||
target[field] = arg;
|
||||
};
|
||||
|
||||
const $unset = <TField extends number | string>(target: Record<TField, object> | (object | null)[], field: TField) => {
|
||||
if (target !== undefined) {
|
||||
if (Array.isArray(target)) {
|
||||
if (field in target) {
|
||||
target[field as number] = null;
|
||||
}
|
||||
} else {
|
||||
delete target[field];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $push = <TField extends string, TItem>(
|
||||
target: Record<TField, TItem[]>,
|
||||
field: TField,
|
||||
arg: TItem | { $each?: TItem; $position?: number; $slice?: number; $sort?: Sort },
|
||||
) => {
|
||||
if (target[field] === undefined) {
|
||||
target[field] = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(target[field])) {
|
||||
throw new Error('Cannot apply $push modifier to non-array');
|
||||
}
|
||||
|
||||
const isEachArgument = (arg: unknown): arg is { $each?: TItem[]; $position?: number; $slice?: number; $sort?: Sort } =>
|
||||
typeof arg === 'object' && arg !== null && '$each' in arg;
|
||||
|
||||
if (!isEachArgument(arg)) {
|
||||
assertHasValidFieldNames(arg);
|
||||
|
||||
target[field].push(arg as TItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const toPush = (arg as { $each: TItem[] }).$each;
|
||||
if (!Array.isArray(toPush)) {
|
||||
throw new Error('$each must be an array');
|
||||
}
|
||||
|
||||
assertHasValidFieldNames(toPush);
|
||||
|
||||
let position = undefined;
|
||||
if ('$position' in arg) {
|
||||
if (typeof arg.$position !== 'number') {
|
||||
throw new Error('$position must be a numeric value');
|
||||
}
|
||||
|
||||
if (arg.$position < 0) {
|
||||
throw new Error('$position in $push must be zero or positive');
|
||||
}
|
||||
|
||||
position = arg.$position;
|
||||
}
|
||||
|
||||
let slice = undefined;
|
||||
if ('$slice' in arg) {
|
||||
if (typeof arg.$slice !== 'number') {
|
||||
throw new Error('$slice must be a numeric value');
|
||||
}
|
||||
|
||||
slice = arg.$slice;
|
||||
}
|
||||
|
||||
let sortFunction = undefined;
|
||||
if (arg.$sort) {
|
||||
if (slice === undefined) {
|
||||
throw new Error('$sort requires $slice to be present');
|
||||
}
|
||||
|
||||
sortFunction = createComparatorFromSort(arg.$sort);
|
||||
|
||||
for (const element of toPush) {
|
||||
if (getBSONType(element) !== BSONType.Object) {
|
||||
throw new Error('$push like modifiers using $sort require all elements to be objects');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (position === undefined) {
|
||||
for (const element of toPush) {
|
||||
target[field].push(element);
|
||||
}
|
||||
} else {
|
||||
const spliceArguments: Parameters<typeof Array.prototype.splice> = [position, 0];
|
||||
|
||||
for (const element of toPush) {
|
||||
spliceArguments.push(element);
|
||||
}
|
||||
|
||||
target[field].splice(...spliceArguments);
|
||||
}
|
||||
|
||||
if (sortFunction) {
|
||||
target[field].sort(sortFunction);
|
||||
}
|
||||
|
||||
if (slice !== undefined) {
|
||||
if (slice === 0) {
|
||||
target[field] = [];
|
||||
} else if (slice < 0) {
|
||||
target[field] = target[field].slice(slice);
|
||||
} else {
|
||||
target[field] = target[field].slice(0, slice);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $addToSet = <TField extends string, TItem>(target: Record<TField, TItem[]>, field: TField, arg: TItem | { $each: TItem[] }) => {
|
||||
const isEachArgument = (arg: unknown): arg is { $each?: TItem[] } => {
|
||||
return typeof arg === 'object' && arg !== null && '$each' in arg && Array.isArray((arg as { $each: TItem[] }).$each);
|
||||
};
|
||||
|
||||
const values = isEachArgument(arg) ? arg.$each : [arg];
|
||||
|
||||
assertHasValidFieldNames(values);
|
||||
|
||||
const toAdd = target[field];
|
||||
if (toAdd === undefined) {
|
||||
target[field] = values;
|
||||
} else if (!Array.isArray(toAdd)) {
|
||||
throw new Error('Cannot apply $addToSet modifier to non-array');
|
||||
} else {
|
||||
for (const value of values) {
|
||||
if (toAdd.some((element) => equals(value, element))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toAdd.push(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $pop = <TField extends string>(target: Record<TField, unknown[]>, field: TField, arg: number | undefined) => {
|
||||
if (target === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toPop = target[field];
|
||||
|
||||
if (toPop === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(toPop)) {
|
||||
throw new Error('Cannot apply $pop modifier to non-array');
|
||||
}
|
||||
|
||||
if (typeof arg === 'number' && arg < 0) {
|
||||
toPop.splice(0, 1);
|
||||
} else {
|
||||
toPop.pop();
|
||||
}
|
||||
};
|
||||
|
||||
const $pull = <TField extends string, TDocument extends object>(
|
||||
target: Record<TField, TDocument[]>,
|
||||
field: TField,
|
||||
arg: Filter<TDocument> | undefined,
|
||||
) => {
|
||||
if (target === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toPull = target[field];
|
||||
if (toPull === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(toPull)) {
|
||||
throw new Error('Cannot apply $pull/pullAll modifier to non-array');
|
||||
}
|
||||
|
||||
let out;
|
||||
if (arg != null && typeof arg === 'object' && !Array.isArray(arg)) {
|
||||
const predicate = createPredicateFromFilter(arg);
|
||||
|
||||
out = toPull.filter((element) => !predicate(element));
|
||||
} else {
|
||||
out = toPull.filter((element) => !equals(element, arg as any));
|
||||
}
|
||||
|
||||
target[field] = out;
|
||||
};
|
||||
|
||||
const $pullAll = <TField extends string, TDocument extends object>(
|
||||
target: Record<TField, TDocument[]>,
|
||||
field: TField,
|
||||
arg: TDocument[],
|
||||
) => {
|
||||
if (!(typeof arg === 'object' && Array.isArray(arg))) {
|
||||
throw new Error('Modifier $pushAll/pullAll allowed for arrays only');
|
||||
}
|
||||
|
||||
if (target === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toPull = target[field];
|
||||
|
||||
if (toPull === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(toPull)) {
|
||||
throw new Error('Cannot apply $pull/pullAll modifier to non-array');
|
||||
}
|
||||
|
||||
target[field] = toPull.filter((object) => !arg.some((element) => equals(object, element)));
|
||||
};
|
||||
|
||||
const $bit = () => {
|
||||
throw new Error('$bit is not supported');
|
||||
};
|
||||
|
||||
const modifiers = {
|
||||
$currentDate,
|
||||
$inc,
|
||||
$min,
|
||||
$max,
|
||||
$mul,
|
||||
$rename,
|
||||
$set,
|
||||
$unset,
|
||||
$push,
|
||||
$addToSet,
|
||||
$pop,
|
||||
$pull,
|
||||
$pullAll,
|
||||
$bit,
|
||||
} as const;
|
||||
|
||||
const findModTarget = (
|
||||
doc: Record<string, any> | unknown[],
|
||||
keyparts: (number | string)[],
|
||||
options: {
|
||||
noCreate?: boolean;
|
||||
forbidArray?: boolean;
|
||||
arrayIndices?: ArrayIndices;
|
||||
} = {},
|
||||
) => {
|
||||
let arrayIdx = 0;
|
||||
|
||||
for (let i = 0; i < keyparts.length; i++) {
|
||||
const last = i === keyparts.length - 1;
|
||||
let keypart: string | number = keyparts[i];
|
||||
|
||||
if (!isIndexable(doc)) {
|
||||
if (options.noCreate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Cannot use the part '${keypart}' to traverse ${doc}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(doc)) {
|
||||
if (options.forbidArray) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keypart === '$') {
|
||||
if (!options.arrayIndices?.length) {
|
||||
throw new Error('The positional operator did not find the match needed from the query');
|
||||
}
|
||||
|
||||
keypart = options.arrayIndices[arrayIdx++];
|
||||
} else if (isNumericKey(keypart as string)) {
|
||||
keypart = parseInt(keypart as string);
|
||||
} else {
|
||||
if (options.noCreate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`can't append to array using string field name [${keypart}]`);
|
||||
}
|
||||
|
||||
if (last) {
|
||||
keyparts[i] = keypart;
|
||||
}
|
||||
|
||||
if (options.noCreate && (keypart as number) >= doc.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
while (doc.length < (keypart as number)) {
|
||||
doc.push(null);
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
if (doc.length === keypart) {
|
||||
doc.push({});
|
||||
} else if (typeof doc[keypart as number] !== 'object') {
|
||||
throw new Error(`can't modify field '${keyparts[i + 1]}' of list value ${JSON.stringify(doc[keypart as number])}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (keypart === '$') {
|
||||
if (!options.arrayIndices?.length) {
|
||||
throw new Error('The positional operator did not find the match needed from the query');
|
||||
}
|
||||
|
||||
keypart = options.arrayIndices[arrayIdx++];
|
||||
|
||||
if (keypart === undefined) {
|
||||
throw new Error('Too many positional');
|
||||
}
|
||||
}
|
||||
|
||||
assertIsValidFieldName(keypart as string);
|
||||
|
||||
if (!(keypart in doc)) {
|
||||
if (options.noCreate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
const nextpart = keyparts[i + 1];
|
||||
doc[keypart] = typeof nextpart === 'number' || /\d+/.test(nextpart) ? [] : {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (last) return doc;
|
||||
|
||||
doc = doc[keypart as keyof typeof doc];
|
||||
}
|
||||
|
||||
throw new Error('Should not reach here');
|
||||
};
|
||||
|
||||
export const createTransformFromUpdateFilter = <T extends { _id: string }>(modifier: UpdateFilter<T>) => {
|
||||
if (!isPlainObject(modifier)) {
|
||||
throw new Error('Modifier must be an object');
|
||||
}
|
||||
|
||||
if (!isUpdateModifiers(modifier)) {
|
||||
assertHasValidFieldNames(modifier);
|
||||
|
||||
return (doc: T): T => {
|
||||
if (doc._id && modifier._id && doc._id !== modifier._id) {
|
||||
throw new Error(`The _id field cannot be changed from {_id: "${doc._id}"} to {_id: "${modifier._id}"}`);
|
||||
}
|
||||
|
||||
return Object.assign({ _id: doc._id } as T, modifier);
|
||||
};
|
||||
}
|
||||
|
||||
return (doc: T, { isInsert = false, arrayIndices }: { isInsert?: boolean; arrayIndices?: ArrayIndices } = {}): T => {
|
||||
const newDoc = clone(doc);
|
||||
|
||||
for (const [operator, operand] of Object.entries(modifier)) {
|
||||
const modFunc = modifiers[isInsert && operator === '$setOnInsert' ? '$set' : (operator as keyof typeof modifiers)] as (
|
||||
target: Record<string, any> | Array<object | null> | null | undefined,
|
||||
field: string,
|
||||
arg: unknown,
|
||||
keypath?: string,
|
||||
doc?: Record<string, any>,
|
||||
) => void;
|
||||
|
||||
if (!modFunc) {
|
||||
throw new Error(`Invalid modifier specified ${operator}`);
|
||||
}
|
||||
|
||||
for (const [keypath, arg] of Object.entries(operand as any)) {
|
||||
if (keypath === '') {
|
||||
throw new Error('An empty update path is not valid.');
|
||||
}
|
||||
|
||||
const keyparts = keypath.split('.');
|
||||
|
||||
if (!keyparts.every(Boolean)) {
|
||||
throw new Error(`The update path '${keypath}' contains an empty field name, which is not allowed.`);
|
||||
}
|
||||
|
||||
const target = findModTarget(newDoc, keyparts, {
|
||||
arrayIndices,
|
||||
forbidArray: operator === '$rename',
|
||||
noCreate: ['$pop', '$pull', '$pullAll', '$rename', '$unset'].includes(operator as keyof typeof modifiers),
|
||||
});
|
||||
|
||||
const key = keyparts.pop();
|
||||
|
||||
if (key === undefined) {
|
||||
throw new Error(`The update path '${keypath}' ends with an empty field name, which is not allowed.`);
|
||||
}
|
||||
|
||||
modFunc(target, key, arg, keypath, newDoc);
|
||||
}
|
||||
}
|
||||
|
||||
if (doc._id && doc._id !== newDoc._id) {
|
||||
throw new Error(
|
||||
`After applying the update to the document {_id: "${doc._id}", ...},` +
|
||||
" the (immutable) field '_id' was found to have been altered to " +
|
||||
`_id: "${newDoc._id}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return newDoc;
|
||||
};
|
||||
};
|
||||
|
||||
export const createUpsertDocument = <T extends { _id: string }>(selector: Filter<T>, modifier: UpdateFilter<T>): T => {
|
||||
let newDoc: Partial<T> = {};
|
||||
|
||||
const selectorDocument = populateDocumentWithQueryFields(selector);
|
||||
|
||||
if (selectorDocument._id) {
|
||||
newDoc._id = selectorDocument._id;
|
||||
delete selectorDocument._id;
|
||||
}
|
||||
|
||||
newDoc = createTransformFromUpdateFilter({ $set: selectorDocument })(newDoc as T) as T;
|
||||
newDoc = createTransformFromUpdateFilter(modifier)(newDoc as T, { isInsert: true });
|
||||
|
||||
if (isUpdateModifiers(modifier)) {
|
||||
return newDoc as T;
|
||||
}
|
||||
|
||||
const replacement = Object.assign({}, modifier);
|
||||
if (newDoc._id) {
|
||||
replacement._id = newDoc._id;
|
||||
}
|
||||
|
||||
return replacement as T;
|
||||
};
|
||||
|
||||
const insertIntoDocument = (document: Document, key: any, value: any) => {
|
||||
Object.keys(document).forEach((existingKey) => {
|
||||
if (
|
||||
(existingKey.length > key.length && existingKey.indexOf(`${key}.`) === 0) ||
|
||||
(key.length > existingKey.length && key.indexOf(`${existingKey}.`) === 0)
|
||||
) {
|
||||
throw new Error(`cannot infer query fields to set, both paths '${existingKey}' and '${key}' are matched`);
|
||||
} else if (existingKey === key) {
|
||||
throw new Error(`cannot infer query fields to set, path '${key}' is matched twice`);
|
||||
}
|
||||
});
|
||||
|
||||
document[key] = value;
|
||||
};
|
||||
|
||||
const populateDocumentWithKeyValue = <T extends { _id: string }>(document: Partial<T>, key: string, value: unknown) => {
|
||||
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
||||
populateDocumentWithObject(document, key, value);
|
||||
} else if (!(value instanceof RegExp)) {
|
||||
insertIntoDocument(document, key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const populateDocumentWithObject = <T extends { _id: string }>(document: Partial<T>, key: string, value: FilterOperators<T>) => {
|
||||
const keys = Object.keys(value);
|
||||
const unprefixedKeys = keys.filter((op) => op[0] !== '$');
|
||||
|
||||
if (unprefixedKeys.length > 0 || !keys.length) {
|
||||
if (keys.length !== unprefixedKeys.length) {
|
||||
throw new Error(`unknown operator: ${unprefixedKeys[0]}`);
|
||||
}
|
||||
|
||||
validateObject(value, key);
|
||||
insertIntoDocument(document, key, value);
|
||||
} else {
|
||||
Object.entries(value).forEach(([op, object]) => {
|
||||
if (op === '$eq') {
|
||||
populateDocumentWithKeyValue(document, key, object as NonNullable<FilterOperators<T>['$eq']>);
|
||||
} else if (op === '$all') {
|
||||
(object as NonNullable<FilterOperators<T>['$all']>).forEach((element: any) => populateDocumentWithKeyValue(document, key, element));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const populateDocumentWithQueryFields = <T extends { _id: string }>(query: T['_id'] | Filter<T>, document: Partial<T> = {}) => {
|
||||
if (typeof query === 'string') {
|
||||
insertIntoDocument(document, '_id', query);
|
||||
return document;
|
||||
}
|
||||
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (key === '$and') {
|
||||
(value as NonNullable<Filter<T>['$and']>).forEach((element) => populateDocumentWithQueryFields<T>(element as Filter<T>, document));
|
||||
} else if (key === '$or') {
|
||||
if ((value as NonNullable<Filter<T>['$or']>).length === 1) {
|
||||
populateDocumentWithQueryFields<T>(value[0], document);
|
||||
}
|
||||
} else if (typeof key === 'string' && key[0] !== '$') {
|
||||
populateDocumentWithKeyValue(document, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
const validateKeyInPath = (key: string, path: string) => {
|
||||
if (key.includes('.')) {
|
||||
throw new Error(`The dotted field '${key}' in '${path}.${key} is not valid for storage.`);
|
||||
}
|
||||
|
||||
if (key[0] === '$') {
|
||||
throw new Error(`The dollar ($) prefixed field '${path}.${key} is not valid for storage.`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateObject = (object: Record<string, unknown>, path: string) => {
|
||||
if (object && Object.getPrototypeOf(object) === Object.prototype) {
|
||||
Object.keys(object).forEach((key) => {
|
||||
validateKeyInPath(key, path);
|
||||
validateObject(object[key] as Record<string, unknown>, `${path}.${key}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user