diff --git a/src/index.js b/src/index.js index 2f4e40a..245f44a 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ function token(name, value) { return { name, value }; } +lexer.addRule(/order by\b/, (l) => token('ORDER BY', l)); lexer.addRule(whitespaceRegex, () => { /* ignore whitespace */ }); lexer.addRule(/\./, (l) => token('DOT', l)); lexer.addRule(/,/, (l) => token('COMMA', l)); @@ -62,6 +63,11 @@ lexer.addRule( new RegExp(`None${reNotFollowedByName}`), (l) => token('NONE', l), ); +lexer.addRule(new RegExp(`asc${reNotFollowedByName}`), (l) => token('ASC', l)); +lexer.addRule( + new RegExp(`desc${reNotFollowedByName}`), + (l) => token('DESC', l) +); lexer.addRule(nameRegex, (l) => token('NAME', l)); lexer.addRule( stringRegex, @@ -631,17 +637,31 @@ DjangoQL.prototype = { getContext(text, cursorPos) { // This function returns an object with the following 4 properties: let prefix; // text already entered by user in the current scope - let scope = null; // 'field', 'comparison', 'value', 'logical' or null + let scope = null; // 'field', 'comparison', 'value', 'logical', + // 'sortdir', or null let model = null; // model, set for 'field', 'comparison' and 'value' let field = null; // field, set for 'comparison' and 'value' // Stack of models that includes all entered models let modelStack = [this.currentModel]; + let nestingLevel = 0; + let queryPart = 'expression'; // poor man's "grammar": we are either + // within the 'expression' part of the query, + // or within the 'ordering' part let nameParts; let resolvedName; let lastToken = null; let nextToLastToken = null; const tokens = this.lexer.setInput(text.slice(0, cursorPos)).lexAll(); + tokens.forEach(t => { + if (t.name === 'ORDER BY') { + queryPart = 'ordering'; + } else if (t.name === 'PAREN_L') { + nestingLevel++; + } else if (t.name == 'PAREN_R') { + nestingLevel--; + } + }); const allTokens = this.lexer.setInput(text).lexAll(); let currentFullToken = null; if (tokens.length && tokens[tokens.length - 1].end >= cursorPos) { @@ -674,12 +694,14 @@ DjangoQL.prototype = { const logicalTokens = ['AND', 'OR']; if (prefix === ')' && !whitespace) { // Nothing to suggest right after right paren - } else if (!lastToken - || (logicalTokens.indexOf(lastToken.name) >= 0 && whitespace) - || (prefix === '.' && lastToken && !whitespace) - || (lastToken.name === 'PAREN_L' - && (!nextToLastToken - || logicalTokens.indexOf(nextToLastToken.name) >= 0))) { + } else if ((queryPart === 'expression' && (!lastToken + || (logicalTokens.indexOf(lastToken.name) >= 0 && whitespace) + || (prefix === '.' && lastToken && !whitespace) + || (lastToken.name === 'PAREN_L' + && (!nextToLastToken + || logicalTokens.indexOf(nextToLastToken.name) >= 0)))) + || (queryPart === 'ordering' && lastToken + && (lastToken.name === 'ORDER BY' || lastToken.name === 'COMMA'))) { scope = 'field'; model = this.currentModel; if (prefix === '.') { @@ -702,7 +724,8 @@ DjangoQL.prototype = { model = null; } } - } else if (lastToken + } else if (queryPart === 'expression' + && lastToken && whitespace && nextToLastToken && nextToLastToken.name === 'NAME' @@ -719,7 +742,8 @@ DjangoQL.prototype = { prefix = prefix.slice(1); } } - } else if (lastToken && whitespace && lastToken.name === 'NAME') { + } else if (queryPart === 'expression' + && lastToken && whitespace && lastToken.name === 'NAME') { resolvedName = this.resolveName(lastToken.value); if (resolvedName.model) { scope = 'comparison'; @@ -727,11 +751,15 @@ DjangoQL.prototype = { field = resolvedName.field; modelStack = resolvedName.modelStack; } - } else if (lastToken + } else if (queryPart === 'expression' + && lastToken && whitespace && ['PAREN_R', 'INT_VALUE', 'FLOAT_VALUE', 'STRING_VALUE'] .indexOf(lastToken.name) >= 0) { scope = 'logical'; + } else if (queryPart === 'ordering' + && lastToken && lastToken.name === 'NAME') { + scope = 'sortdir'; } return { @@ -741,6 +769,8 @@ DjangoQL.prototype = { field, currentFullToken, modelStack, + queryPart, + nestingLevel, }; }, @@ -928,6 +958,9 @@ DjangoQL.prototype = { }).map((f) => ( suggestion(f, '', model[f].type === 'relation' ? '.' : ' ') )); + if (context.queryPart === 'expression' && context.nestingLevel === 0) { + this.suggestions.push(suggestion("order by", "", " ")); + } break; case 'comparison': @@ -1007,8 +1040,18 @@ DjangoQL.prototype = { suggestion('and', '', ' '), suggestion('or', '', ' '), ]; + if (context.nestingLevel === 0) { + this.suggestions.push(suggestion('order by', '', ' ')); + } break; + case 'sortdir': + this.suggestions = [ + suggestion('asc', '', ' '), + suggestion('desc', '', ' ') + ]; + break; + default: this.prefix = ''; this.suggestions = []; diff --git a/tests/DjangoQL.test.js b/tests/DjangoQL.test.js index 6fbb461..901e8c6 100644 --- a/tests/DjangoQL.test.js +++ b/tests/DjangoQL.test.js @@ -139,7 +139,7 @@ describe('test DjangoQL completion', () => { token('CONTAINS', '~'), token('NOT_CONTAINS', '!~'), ]; - djangoQL.lexer.setInput('() ., = != >\t >= < <= ~ !~'); + djangoQL.lexer.setInput('() ., = != >\t >= < <= ~ !~ '); tokens.forEach((t) => { expect(djangoQL.lexer.lex()).toStrictEqual(t); }); @@ -147,7 +147,7 @@ describe('test DjangoQL completion', () => { }); it('should recognize names', () => { - const names = ['a', 'myVar_42', '__LOL__', '_', '_0']; + const names = ['a', 'myVar_42', '__LOL__', '_', '_0', 'order']; djangoQL.lexer.setInput(names.join(' ')); names.forEach((name) => { expect(djangoQL.lexer.lex()).toStrictEqual(token('NAME', name)); @@ -155,7 +155,8 @@ describe('test DjangoQL completion', () => { }); it('should recognize reserved words', () => { - const words = ['True', 'False', 'None', 'or', 'and', 'in']; + const words = ['True', 'False', 'None', 'or', 'and', 'in', + 'order by', 'asc', 'desc']; djangoQL.lexer.setInput(words.join(' ')); words.forEach((word) => { expect(djangoQL.lexer.lex()) @@ -257,7 +258,7 @@ describe('test DjangoQL completion', () => { }); describe('.getScope()', () => { - it('should properly detect scope and prefix', () => { + it('should properly detect scope, prefix, nesting level, and query part', () => { const book = djangoQL.currentModel; const examples = [ { @@ -267,6 +268,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -276,6 +279,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -285,6 +290,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -294,6 +301,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -303,6 +312,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -312,6 +323,8 @@ describe('test DjangoQL completion', () => { scope: 'comparison', model: book, field: 'id', + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -321,6 +334,8 @@ describe('test DjangoQL completion', () => { scope: 'comparison', model: book, field: 'id', + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -330,6 +345,8 @@ describe('test DjangoQL completion', () => { scope: 'value', model: book, field: 'id', + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -339,6 +356,8 @@ describe('test DjangoQL completion', () => { scope: 'value', model: book, field: 'id', + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -348,6 +367,8 @@ describe('test DjangoQL completion', () => { scope: 'logical', model: null, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -357,6 +378,8 @@ describe('test DjangoQL completion', () => { scope: 'logical', model: null, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -366,6 +389,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -375,6 +400,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: 'auth.user', field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -384,6 +411,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: 'auth.user', field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -393,6 +422,8 @@ describe('test DjangoQL completion', () => { scope: 'logical', model: null, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -402,6 +433,8 @@ describe('test DjangoQL completion', () => { scope: 'logical', model: null, field: null, + nestingLevel: 0, + queryPart: 'expression', }, }, { @@ -411,6 +444,8 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 1, + queryPart: 'expression', }, }, { @@ -420,6 +455,42 @@ describe('test DjangoQL completion', () => { scope: 'field', model: book, field: null, + nestingLevel: 1, + queryPart: 'expression', + }, + }, + { + args: ['order by foo', 7], // cursor is inside the `order by` clause + result: { + prefix: 'b', + scope: null, + model: null, + field: null, + nestingLevel: 0, + queryPart: 'expression', + }, + }, + { + args: ['order by foo', 10], // cursor is in field after `order by` + result: { + prefix: 'f', + scope: 'field', + model: book, + field: null, + nestingLevel: 0, + queryPart: 'ordering', + }, + }, + { + // cursor is in field after `order by` + args: ['id > 10 order by id desc', 7], + result: { + prefix: '10', + scope: 'value', + model: book, + field: 'id', + nestingLevel: 0, + queryPart: 'expression', }, }, ];