Skip to content

Commit 8e00267

Browse files
authored
fix: allow multiple conditions in queries (#15)
* fix: allow multiple conditions in queries * refactor: split inline methods into private * refactor: inline simple checks
1 parent c0f6db5 commit 8e00267

File tree

6 files changed

+97
-21
lines changed

6 files changed

+97
-21
lines changed

src/Query.js

+55-19
Original file line numberDiff line numberDiff line change
@@ -50,32 +50,68 @@ class Query {
5050
* @returns {Array<Model>} The models that match the query.
5151
*/
5252
execute(model, index) {
53-
const matchIs = (query) => query?.$is !== undefined;
54-
const matchPrimitive = (query) => ['string', 'number', 'boolean'].includes(typeof query);
55-
const matchContains = (query) => query?.$contains !== undefined;
53+
return Object.values(index)
54+
.filter(m =>
55+
this._splitQuery(this.query)
56+
.map(query => Boolean(this._matchesQuery(m, query)))
57+
.every(c => c),
58+
)
59+
.map(m => model.fromData(m));
60+
}
5661

57-
const matchesQuery = (subject, inputQuery = this.query) => {
58-
if (matchPrimitive(inputQuery)) return subject === inputQuery;
62+
/**
63+
* Recursively checks if a subject matches a given query.
64+
*
65+
* This function supports matching:
66+
* - Primitive values directly (`string`, `number`, `boolean`)
67+
* - The `$is` property for exact matches
68+
* - The `$contains` property for substring or array element matches
69+
*
70+
* @private
71+
* @param {*} subject - The subject to be matched.
72+
* @param {Object} [inputQuery=this.query] - The query to match against. Defaults to `this.query` if not provided.
73+
* @returns {boolean} True if the subject matches the query, otherwise false.
74+
*/
75+
_matchesQuery(subject, inputQuery = this.query) {
76+
if (['string', 'number', 'boolean'].includes(typeof inputQuery)) return subject === inputQuery;
5977

60-
if (matchIs(inputQuery) && subject === inputQuery.$is) return true;
78+
if (inputQuery?.$is !== undefined && subject === inputQuery.$is) return true;
6179

62-
if (matchContains(inputQuery)) {
63-
if (subject.includes?.(inputQuery.$contains)) return true;
80+
if (inputQuery?.$contains !== undefined) {
81+
if (subject.includes?.(inputQuery.$contains)) return true;
6482

65-
for (const value of subject) {
66-
if (matchesQuery(value, inputQuery.$contains)) return true;
67-
}
83+
for (const value of subject) {
84+
if (this._matchesQuery(value, inputQuery.$contains)) return true;
6885
}
86+
}
6987

70-
for (const key of Object.keys(inputQuery)) {
71-
if (!['$is', '$contains'].includes(key))
72-
if (matchesQuery(subject[key], inputQuery[key])) return true;
73-
}
74-
};
88+
for (const key of Object.keys(inputQuery)) {
89+
if (!['$is', '$contains'].includes(key))
90+
if (this._matchesQuery(subject[key], inputQuery[key])) return true;
91+
}
7592

76-
return Object.values(index)
77-
.filter(m => matchesQuery(m))
78-
.map(m => model.fromData(m));
93+
return false;
94+
};
95+
96+
/**
97+
* Recursively splits an object into an array of objects,
98+
* where each key-value pair from the input query becomes a separate object.
99+
*
100+
* If the value of a key is a nested object (and not an array),
101+
* the function recursively splits it, preserving the parent key.
102+
*
103+
* @private
104+
* @param {Object} query - The input object to be split into individual key-value pairs.
105+
* @returns {Array<Object>} An array of objects, where each object contains a single key-value pair
106+
* from the original query or its nested objects.
107+
*/
108+
_splitQuery(query) {
109+
return Object.entries(query)
110+
.flatMap(([key, value]) =>
111+
typeof value === 'object' && value !== null && !Array.isArray(value)
112+
? this._splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
113+
: {[key]: value},
114+
);
79115
}
80116
}
81117

src/Query.test.js

+35
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ test('Query.execute(index) finds exact number matches with primitive type', t =>
7171
t.like(results, [model.toIndexData()]);
7272
});
7373

74+
test('Query.execute(index) finds exact string matches with slug type', t => {
75+
const models = new Models();
76+
const model = models.createFullTestModel();
77+
78+
const query = new Query({stringSlug: 'test'});
79+
const results = query.execute(MainModel, models.getIndex(MainModel));
80+
81+
t.like(results, [model.toIndexData()]);
82+
});
83+
7484
test('Query.execute(index) finds matches containing for strings', t => {
7585
const models = new Models();
7686
const model1 = models.createFullTestModel();
@@ -123,3 +133,28 @@ test('Query.execute(index) finds partial matches for elements in arrays', t => {
123133
model2.toIndexData(),
124134
]);
125135
});
136+
137+
test('Query.execute(index) finds matches for multiple inclusive conditions', t => {
138+
const models = new Models();
139+
const model1 = models.createFullTestModel();
140+
models.createFullTestModel();
141+
142+
model1.boolean = true;
143+
144+
const query = new Query({string: {$is: 'test'}, boolean: true});
145+
const results = query.execute(MainModel, models.getIndex(MainModel));
146+
147+
t.deepEqual(results, [MainModel.fromData(model1.toIndexData())]);
148+
});
149+
150+
test('Query.execute(index) finds matches for multiple inclusive nested conditions', t => {
151+
const models = new Models();
152+
const model1 = models.createFullTestModel();
153+
models.createFullTestModel();
154+
model1.linked.boolean = false;
155+
156+
const query = new Query({linked: {string: 'test', boolean: false}});
157+
const results = query.execute(MainModel, models.getIndex(MainModel));
158+
159+
t.deepEqual(results, [MainModel.fromData(model1.toIndexData())]);
160+
});

src/Transactions.test.js

+2
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ test('transaction.commit() reverts already commited changes if the transaction f
142142
assertions.calledWith(t, testEngine.putModel, {
143143
id: 'LinkedModel/000000000000',
144144
string: 'updated',
145+
boolean: true,
145146
});
146147
assertions.calledWith(t, testEngine.putModel, {
147148
id: 'LinkedModel/000000000000',
148149
string: 'test',
150+
boolean: true,
149151
});
150152
});

src/type/Model.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ test('model.toIndexData() returns an object with the index properties', t => {
3535
arrayOfString: ['test'],
3636
boolean: false,
3737
id: 'MainModel/000000000000',
38-
linked: {string: 'test'},
38+
linked: {string: 'test', boolean: true},
3939
linkedMany: [{string: 'many'}],
4040
number: 24.3,
4141
string: 'test',

test/fixtures/ModelCollection.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export class Models {
161161
model.id = this.getNextModelId(model);
162162
this.addModel(model);
163163

164-
const linked = new LinkedModel({string: 'test'});
164+
const linked = new LinkedModel({string: 'test', boolean: true});
165165
linked.id = this.getNextModelId(linked);
166166
model.linked = linked;
167167
this.addModel(linked);

test/fixtures/Models.js

+3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import Type from '../../src/type/index.js';
66
* @class LinkedModel
77
* @extends {Type.Model}
88
* @property {Type.String} string - A string type property.
9+
* @property {Type.Boolean} boolean - A boolean type property.
910
*/
1011
export class LinkedModel extends Type.Model {
1112
static string = Type.String;
13+
static boolean = Type.Boolean;
1214
}
1315

1416
/**
@@ -130,6 +132,7 @@ export class MainModel extends Type.Model {
130132
'arrayOfString',
131133
'stringSlug',
132134
'linked.string',
135+
'linked.boolean',
133136
'linkedMany.[*].string',
134137
];
135138

0 commit comments

Comments
 (0)