Skip to content

Commit

Permalink
fix: allow multiple conditions in queries (#15)
Browse files Browse the repository at this point in the history
* fix: allow multiple conditions in queries

* refactor: split inline methods into private

* refactor: inline simple checks
  • Loading branch information
acodeninja authored Sep 25, 2024
1 parent c0f6db5 commit 8e00267
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 21 deletions.
74 changes: 55 additions & 19 deletions src/Query.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,32 +50,68 @@ class Query {
* @returns {Array<Model>} The models that match the query.
*/
execute(model, index) {
const matchIs = (query) => query?.$is !== undefined;
const matchPrimitive = (query) => ['string', 'number', 'boolean'].includes(typeof query);
const matchContains = (query) => query?.$contains !== undefined;
return Object.values(index)
.filter(m =>
this._splitQuery(this.query)
.map(query => Boolean(this._matchesQuery(m, query)))
.every(c => c),
)
.map(m => model.fromData(m));
}

const matchesQuery = (subject, inputQuery = this.query) => {
if (matchPrimitive(inputQuery)) return subject === inputQuery;
/**
* Recursively checks if a subject matches a given query.
*
* This function supports matching:
* - Primitive values directly (`string`, `number`, `boolean`)
* - The `$is` property for exact matches
* - The `$contains` property for substring or array element matches
*
* @private
* @param {*} subject - The subject to be matched.
* @param {Object} [inputQuery=this.query] - The query to match against. Defaults to `this.query` if not provided.
* @returns {boolean} True if the subject matches the query, otherwise false.
*/
_matchesQuery(subject, inputQuery = this.query) {
if (['string', 'number', 'boolean'].includes(typeof inputQuery)) return subject === inputQuery;

if (matchIs(inputQuery) && subject === inputQuery.$is) return true;
if (inputQuery?.$is !== undefined && subject === inputQuery.$is) return true;

if (matchContains(inputQuery)) {
if (subject.includes?.(inputQuery.$contains)) return true;
if (inputQuery?.$contains !== undefined) {
if (subject.includes?.(inputQuery.$contains)) return true;

for (const value of subject) {
if (matchesQuery(value, inputQuery.$contains)) return true;
}
for (const value of subject) {
if (this._matchesQuery(value, inputQuery.$contains)) return true;
}
}

for (const key of Object.keys(inputQuery)) {
if (!['$is', '$contains'].includes(key))
if (matchesQuery(subject[key], inputQuery[key])) return true;
}
};
for (const key of Object.keys(inputQuery)) {
if (!['$is', '$contains'].includes(key))
if (this._matchesQuery(subject[key], inputQuery[key])) return true;
}

return Object.values(index)
.filter(m => matchesQuery(m))
.map(m => model.fromData(m));
return false;
};

/**
* Recursively splits an object into an array of objects,
* where each key-value pair from the input query becomes a separate object.
*
* If the value of a key is a nested object (and not an array),
* the function recursively splits it, preserving the parent key.
*
* @private
* @param {Object} query - The input object to be split into individual key-value pairs.
* @returns {Array<Object>} An array of objects, where each object contains a single key-value pair
* from the original query or its nested objects.
*/
_splitQuery(query) {
return Object.entries(query)
.flatMap(([key, value]) =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? this._splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
: {[key]: value},
);
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/Query.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ test('Query.execute(index) finds exact number matches with primitive type', t =>
t.like(results, [model.toIndexData()]);
});

test('Query.execute(index) finds exact string matches with slug type', t => {
const models = new Models();
const model = models.createFullTestModel();

const query = new Query({stringSlug: 'test'});
const results = query.execute(MainModel, models.getIndex(MainModel));

t.like(results, [model.toIndexData()]);
});

test('Query.execute(index) finds matches containing for strings', t => {
const models = new Models();
const model1 = models.createFullTestModel();
Expand Down Expand Up @@ -123,3 +133,28 @@ test('Query.execute(index) finds partial matches for elements in arrays', t => {
model2.toIndexData(),
]);
});

test('Query.execute(index) finds matches for multiple inclusive conditions', t => {
const models = new Models();
const model1 = models.createFullTestModel();
models.createFullTestModel();

model1.boolean = true;

const query = new Query({string: {$is: 'test'}, boolean: true});
const results = query.execute(MainModel, models.getIndex(MainModel));

t.deepEqual(results, [MainModel.fromData(model1.toIndexData())]);
});

test('Query.execute(index) finds matches for multiple inclusive nested conditions', t => {
const models = new Models();
const model1 = models.createFullTestModel();
models.createFullTestModel();
model1.linked.boolean = false;

const query = new Query({linked: {string: 'test', boolean: false}});
const results = query.execute(MainModel, models.getIndex(MainModel));

t.deepEqual(results, [MainModel.fromData(model1.toIndexData())]);
});
2 changes: 2 additions & 0 deletions src/Transactions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ test('transaction.commit() reverts already commited changes if the transaction f
assertions.calledWith(t, testEngine.putModel, {
id: 'LinkedModel/000000000000',
string: 'updated',
boolean: true,
});
assertions.calledWith(t, testEngine.putModel, {
id: 'LinkedModel/000000000000',
string: 'test',
boolean: true,
});
});
2 changes: 1 addition & 1 deletion src/type/Model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test('model.toIndexData() returns an object with the index properties', t => {
arrayOfString: ['test'],
boolean: false,
id: 'MainModel/000000000000',
linked: {string: 'test'},
linked: {string: 'test', boolean: true},
linkedMany: [{string: 'many'}],
number: 24.3,
string: 'test',
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/ModelCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class Models {
model.id = this.getNextModelId(model);
this.addModel(model);

const linked = new LinkedModel({string: 'test'});
const linked = new LinkedModel({string: 'test', boolean: true});
linked.id = this.getNextModelId(linked);
model.linked = linked;
this.addModel(linked);
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/Models.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import Type from '../../src/type/index.js';
* @class LinkedModel
* @extends {Type.Model}
* @property {Type.String} string - A string type property.
* @property {Type.Boolean} boolean - A boolean type property.
*/
export class LinkedModel extends Type.Model {
static string = Type.String;
static boolean = Type.Boolean;
}

/**
Expand Down Expand Up @@ -130,6 +132,7 @@ export class MainModel extends Type.Model {
'arrayOfString',
'stringSlug',
'linked.string',
'linked.boolean',
'linkedMany.[*].string',
];

Expand Down

0 comments on commit 8e00267

Please sign in to comment.