Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion controllers/goto/ideas.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
withCreators: route(['query.filter.creators']),
commentedBy: route(['query.filter.commentedBy']),
highlyVoted: route(['query.filter.highlyVoted']),
trending: route(['query.filter.trending'])
trending: route(['query.filter.trending']),
searchTitle: route(['query.filter.title.like'])
},
};
25 changes: 24 additions & 1 deletion controllers/ideas.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,27 @@ async function getIdeasTrending(req, res, next) {
}
}

module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };
/**
* Get ideas with any of specified keywords in title
*/
async function getIdeasSearchTitle(req, res, next) {
try {
// gather data
const { page: { offset = 0, limit = 10 } = { } } = req.query;
const { like: keywords } = req.query.filter.title;

// read ideas from database
const foundIdeas = await models.idea.findWithTitleKeywords(keywords, { offset, limit });
// serialize
const serializedIdeas = serialize.idea(foundIdeas);

// respond
return res.status(200).json(serializedIdeas);

} catch (e) {
return next(e);
}
}


module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };
1 change: 1 addition & 0 deletions controllers/validators/ideas.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
get: validate('getIdea'),
getIdeasCommentedBy: validate('getIdeasCommentedBy'),
getIdeasHighlyVoted: validate('getIdeasHighlyVoted'),
getIdeasSearchTitle: validate('getIdeasSearchTitle'),
getIdeasTrending: validate('getIdeasTrending'),
getIdeasWithCreators: validate('getIdeasWithCreators'),
getIdeasWithMyTags: validate('getIdeasWithMyTags'),
Expand Down
5 changes: 4 additions & 1 deletion controllers/validators/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ const parametersDictionary = {
size: 'int',
creators: 'array',
commentedBy: 'array',
highlyVoted: 'int'
highlyVoted: 'int',
title: {
like: 'array'
}
},
};

Expand Down
8 changes: 7 additions & 1 deletion controllers/validators/schema/definitions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const { tagname, username } = require('./paths');
const { tagname, title, username } = require('./paths');

module.exports = {
shared: {
Expand Down Expand Up @@ -174,6 +174,12 @@ module.exports = {
items: username,
maxItems: 10,
minItems: 1
},
keywordsList: {
type: 'array',
items: title,
maxItems: 10,
minItems: 1
}
}
};
31 changes: 28 additions & 3 deletions controllers/validators/schema/ideas.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict';

const { title, detail, id, page, pageOffset0, random, tagsList, usersList } = require('./paths');

const { title, detail, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths');
const postIdeas = {
properties: {
body: {
Expand Down Expand Up @@ -218,4 +217,30 @@ const getIdeasTrending = {
required: ['query']
};

module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };
const getIdeasSearchTitle = {
properties: {
query: {
properties: {
filter: {
properties: {
title: {
properties: {
like: keywordsList
},
required: ['like'],
additionalProperties: false
}
},
required: ['title'],
additionalProperties: false
},
page
},
required: ['filter'],
additionalProperties: false
},
},
required: ['query']
};

module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };
1 change: 1 addition & 0 deletions controllers/validators/schema/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ module.exports = {
random: { $ref: 'sch#/definitions/query/random' },
tagsList: { $ref: 'sch#/definitions/query/tagsList' },
usersList: { $ref: 'sch#/definitions/query/usersList' },
keywordsList: { $ref: 'sch#/definitions/query/keywordsList' },
ideaId: { $ref : 'sch#/definitions/idea/ideaId' },
title: { $ref: 'sch#/definitions/idea/titl' },
detail: { $ref: 'sch#/definitions/idea/detail' },
Expand Down
32 changes: 32 additions & 0 deletions models/idea/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,38 @@ class Idea extends Model {
const cursor = await this.db.query(query, params);
return await cursor.all();
}


/**
* Read ideas with any of specified keywords in the title
* @param {string[]} keywords - list of keywords to search with
* @param {integer} offset - pagination offset
* @param {integer} limit - pagination limit
* @returns {Promise<Idea[]>} - list of found ideas
*/
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could also think whether we need substring search or just word or perfix search (those can be faster)

Copy link
Copy Markdown
Member

@mrkvon mrkvon Apr 10, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More efficient search can be nice, actually. Especially if we get a lot of ideas and if the search has lower computational complexity (O(n) vs. O(1)?). It can be done later, when it's needed, can't it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arango supports word-based prefix matching from what I got on slack(this has probably some index support). If we want something different we can explore it later.

FOR idea IN FULLTEXT(ideas, "title", CONCAT("prefix:", CONCAT_SEPARATOR(",|prefix:", @Keywords)))
RETURN idea

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opened issue #65 for this

static async findWithTitleKeywords(keywords, { offset, limit }) {
const query = `
FOR idea IN ideas
LET search = ( FOR keyword in @keywords
RETURN TO_NUMBER(CONTAINS(idea.title, keyword)))
LET fit = SUM(search)
FILTER fit > 0
// find creator
LET c = (DOCUMENT(idea.creator))
// format for output
LET creator = MERGE(KEEP(c, 'username'), c.profile)
LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }, {fit})
// sort from newest
SORT fit DESC, ideaOut.title
// limit
LIMIT @offset, @limit
// respond
RETURN ideaOut`;

const params = { 'keywords': keywords, offset, limit };
const cursor = await this.db.query(query, params);
return await cursor.all();
}
}


Expand Down
5 changes: 5 additions & 0 deletions routes/ideas.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ router.route('/')
router.route('/')
.get(go.get.trending, authorize.onlyLogged, parse, ideaValidators.getIdeasTrending, ideaControllers.getIdeasTrending);

// get ideas with keywords
router.route('/')
.get(go.get.searchTitle, authorize.onlyLogged, parse, ideaValidators.getIdeasSearchTitle, ideaControllers.getIdeasSearchTitle);


router.route('/:id')
// read idea by id
.get(authorize.onlyLogged, ideaValidators.get, ideaControllers.get)
Expand Down
139 changes: 139 additions & 0 deletions test/ideas.list.js
Original file line number Diff line number Diff line change
Expand Up @@ -1006,4 +1006,143 @@ describe('read lists of ideas', () => {
});
});
});

describe('GET /ideas?filter[title][like]=string1,string2,string3', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So do I understand correctly that the client will be responsible for splitting the user's raw input into separate words (and send them as separate query items)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it as about keywords, where each keyword is added separately and consciously by the user on the client side. Not like dividing a string into smaller pieces.
I am happy here with every way which will work and be useful for the user.

let user0;
// create and save testing data
beforeEach(async () => {
const data = {
users: 2,
verifiedUsers: [0],
ideas: [ [{title:'idea-title1'}, 0], [{title:'idea-title2-keyword1'}, 0], [{title:'idea-title3-keyword2'}, 0], [{title:'idea-title4-keyword3'}, 0], [{title:'idea-title5-keyword2-keyword3'}, 0], [{title:'idea-title6-keyword1'}, 0], [{title:'idea-title7-keyword1-keyword4'}, 0] ]
};

dbData = await dbHandle.fill(data);

[user0, ] = dbData.users;
});

context('logged in', () => {

beforeEach(() => {
agent = agentFactory.logged(user0);
});

context('valid data', () => {

it('[find ideas with one word] 200 and return array of matched ideas', async () => {

// request
const response = await agent
.get('/ideas?filter[title][like]=keyword1')
.expect(200);

// we should find 2 ideas...
should(response.body).have.property('data').Array().length(3);

// sorted by creation date desc
should(response.body.data.map(idea => idea.attributes.title))
.eql(['idea-title2-keyword1','idea-title6-keyword1', 'idea-title7-keyword1-keyword4']);

});


it('[find ideas with two words] 200 and return array of matched ideas', async () => {

// request
const response = await agent
.get('/ideas?filter[title][like]=keyword2,keyword3')
.expect(200);

// we should find 4 ideas...
should(response.body).have.property('data').Array().length(3);

// sorted by creation date desc
should(response.body.data.map(idea => idea.attributes.title))
.eql(['idea-title5-keyword2-keyword3', 'idea-title3-keyword2', 'idea-title4-keyword3']);
});

it('[find ideas with word not present in any] 200 and return array of matched ideas', async () => {

// request
const response = await agent
.get('/ideas?filter[title][like]=keyword10')
.expect(200);

// we should find 0 ideas...
should(response.body).have.property('data').Array().length(0);

});

it('[pagination] offset and limit the results', async () => {
const response = await agent
.get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=2')
.expect(200);

// we should find 3 ideas
should(response.body).have.property('data').Array().length(2);

// sorted by creation date desc
should(response.body.data.map(idea => idea.attributes.title))
.eql(['idea-title6-keyword1', 'idea-title7-keyword1-keyword4']);
});

it('should be fine to provide a keyword which includes empty spaces and/or special characters', async () => {
// request
await agent
.get('/ideas?filter[title][like]=keyword , aa,1-i')
.expect(200);
});

});

context('invalid data', () => {

it('[too many keywords] 400', async () => {
await agent
.get('/ideas?filter[title][like]=keyword1,keyword2,keyword3,keyword4,keyword5,keyword6,keyword7,keyword8,keyword9,keyword10,keyword11')
.expect(400);
});

it('[empty keywords] 400', async () => {
await agent
.get('/ideas?filter[title][like]=keyword1,')
.expect(400);
});

it('[too long keywords] 400', async () => {
await agent
.get(`/ideas?filter[title][like]=keyword1,${'a'.repeat(257)}`)
.expect(400);
});

it('[keywords spaces only] 400', async () => {
await agent
.get('/ideas?filter[title][like]= ,keyword2')
.expect(400);
});

it('[invalid pagination] 400', async () => {
await agent
.get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=21')
.expect(400);
});

it('[unexpected query params] 400', async () => {
await agent
.get('/ideas?filter[title][like]=keyword1&additional[param]=3&page[offset]=1&page[limit]=3')
.expect(400);
});
});
});

context('not logged in', () => {
it('403', async () => {
await agent
.get('/ideas?filter[title][like]=keyword1')
.expect(403);
});
});
});

});