tokenizeSearch.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. import {MutableSearch, TokenType} from 'sentry/utils/tokenizeSearch';
  2. describe('utils/tokenizeSearch', function () {
  3. describe('new MutableSearch()', function () {
  4. const cases = [
  5. {
  6. name: 'should convert a basic query string to a query object',
  7. string: 'is:unresolved',
  8. object: {
  9. tokens: [{type: TokenType.FILTER, key: 'is', value: 'unresolved'}],
  10. },
  11. },
  12. {
  13. name: 'should convert quoted strings',
  14. string: 'is:unresolved browser:"Chrome 36"',
  15. object: {
  16. tokens: [
  17. {type: TokenType.FILTER, key: 'is', value: 'unresolved'},
  18. {type: TokenType.FILTER, key: 'browser', value: 'Chrome 36'},
  19. ],
  20. },
  21. },
  22. {
  23. name: 'should populate the text query',
  24. string: 'python is:unresolved browser:"Chrome 36"',
  25. object: {
  26. tokens: [
  27. {type: TokenType.FREE_TEXT, value: 'python'},
  28. {type: TokenType.FILTER, key: 'is', value: 'unresolved'},
  29. {type: TokenType.FILTER, key: 'browser', value: 'Chrome 36'},
  30. ],
  31. },
  32. },
  33. {
  34. name: 'should tokenize the text query',
  35. string: 'python exception',
  36. object: {
  37. tokens: [
  38. {type: TokenType.FREE_TEXT, value: 'python'},
  39. {type: TokenType.FREE_TEXT, value: 'exception'},
  40. ],
  41. },
  42. },
  43. {
  44. name: 'should tokenize has condition',
  45. string: 'has:user has:browser',
  46. object: {
  47. tokens: [
  48. {type: TokenType.FILTER, key: 'has', value: 'user'},
  49. {type: TokenType.FILTER, key: 'has', value: 'browser'},
  50. ],
  51. },
  52. },
  53. {
  54. name: 'should tokenize !has condition',
  55. string: '!has:user has:browser',
  56. object: {
  57. tokens: [
  58. {type: TokenType.FILTER, key: '!has', value: 'user'},
  59. {type: TokenType.FILTER, key: 'has', value: 'browser'},
  60. ],
  61. },
  62. },
  63. {
  64. name: 'should remove spaces in the query',
  65. string: 'python is:unresolved exception',
  66. object: {
  67. tokens: [
  68. {type: TokenType.FREE_TEXT, value: 'python'},
  69. {type: TokenType.FILTER, key: 'is', value: 'unresolved'},
  70. {type: TokenType.FREE_TEXT, value: 'exception'},
  71. ],
  72. },
  73. },
  74. {
  75. name: 'should tokenize the quoted tags',
  76. string: 'event.type:error title:"QueryExecutionError: Code: 141."',
  77. object: {
  78. tokens: [
  79. {type: TokenType.FILTER, key: 'event.type', value: 'error'},
  80. {
  81. type: TokenType.FILTER,
  82. key: 'title',
  83. value: 'QueryExecutionError: Code: 141.',
  84. },
  85. ],
  86. },
  87. },
  88. {
  89. name: 'should tokenize words with :: in them',
  90. string: 'key:Resque::DirtyExit',
  91. object: {
  92. tokens: [{type: TokenType.FILTER, key: 'key', value: 'Resque::DirtyExit'}],
  93. },
  94. },
  95. {
  96. name: 'tokens that begin with a colon are still queries',
  97. string: 'country:canada :unresolved',
  98. object: {
  99. tokens: [
  100. {type: TokenType.FILTER, key: 'country', value: 'canada'},
  101. {type: TokenType.FREE_TEXT, value: ':unresolved'},
  102. ],
  103. },
  104. },
  105. {
  106. name: 'correctly preserve boolean operators',
  107. string: 'country:canada Or country:newzealand',
  108. object: {
  109. tokens: [
  110. {type: TokenType.FILTER, key: 'country', value: 'canada'},
  111. {type: TokenType.OPERATOR, value: 'OR'},
  112. {type: TokenType.FILTER, key: 'country', value: 'newzealand'},
  113. ],
  114. },
  115. },
  116. {
  117. name: 'correctly preserve parens',
  118. string: '(country:canada Or country:newzealand) AnD province:pei',
  119. object: {
  120. tokens: [
  121. {type: TokenType.OPERATOR, value: '('},
  122. {type: TokenType.FILTER, key: 'country', value: 'canada'},
  123. {type: TokenType.OPERATOR, value: 'OR'},
  124. {type: TokenType.FILTER, key: 'country', value: 'newzealand'},
  125. {type: TokenType.OPERATOR, value: ')'},
  126. {type: TokenType.OPERATOR, value: 'AND'},
  127. {type: TokenType.FILTER, key: 'province', value: 'pei'},
  128. ],
  129. },
  130. },
  131. {
  132. name: 'query tags boolean and parens are all stitched back together correctly',
  133. string: '(a:a OR (b:b AND c d e)) OR f g:g',
  134. object: {
  135. tokens: [
  136. {type: TokenType.OPERATOR, value: '('},
  137. {type: TokenType.FILTER, key: 'a', value: 'a'},
  138. {type: TokenType.OPERATOR, value: 'OR'},
  139. {type: TokenType.OPERATOR, value: '('},
  140. {type: TokenType.FILTER, key: 'b', value: 'b'},
  141. {type: TokenType.OPERATOR, value: 'AND'},
  142. {type: TokenType.FREE_TEXT, value: 'c'},
  143. {type: TokenType.FREE_TEXT, value: 'd'},
  144. {type: TokenType.FREE_TEXT, value: 'e'},
  145. {type: TokenType.OPERATOR, value: ')'},
  146. {type: TokenType.OPERATOR, value: ')'},
  147. {type: TokenType.OPERATOR, value: 'OR'},
  148. {type: TokenType.FREE_TEXT, value: 'f'},
  149. {type: TokenType.FILTER, key: 'g', value: 'g'},
  150. ],
  151. },
  152. },
  153. {
  154. name: 'correctly preserve filters with functions',
  155. string: 'country:>canada OR coronaFree():<newzealand',
  156. object: {
  157. tokens: [
  158. {type: TokenType.FILTER, key: 'country', value: '>canada'},
  159. {type: TokenType.OPERATOR, value: 'OR'},
  160. {type: TokenType.FILTER, key: 'coronaFree()', value: '<newzealand'},
  161. ],
  162. },
  163. },
  164. {
  165. name: 'correctly preserves leading/trailing escaped quotes',
  166. string: 'a:"\\"a\\""',
  167. object: {
  168. tokens: [{type: TokenType.FILTER, key: 'a', value: '\\"a\\"'}],
  169. },
  170. },
  171. {
  172. name: 'correctly tokenizes escaped quotes',
  173. string: 'a:"i \\" quote" b:"b\\"bb" c:"cc"',
  174. object: {
  175. tokens: [
  176. {type: TokenType.FILTER, key: 'a', value: 'i \\" quote'},
  177. {type: TokenType.FILTER, key: 'b', value: 'b\\"bb'},
  178. {type: TokenType.FILTER, key: 'c', value: 'cc'},
  179. ],
  180. },
  181. },
  182. ];
  183. for (const {name, string, object} of cases) {
  184. // eslint-disable-next-line jest/valid-title
  185. it(name, () => expect(new MutableSearch(string)).toEqual(object));
  186. }
  187. });
  188. describe('QueryResults operations', function () {
  189. it('add tokens to query object', function () {
  190. const results = new MutableSearch([]);
  191. results.addStringFilter('a:a');
  192. expect(results.formatString()).toEqual('a:a');
  193. results.addFilterValues('b', ['b']);
  194. expect(results.formatString()).toEqual('a:a b:b');
  195. results.addFilterValues('c', ['c1', 'c2']);
  196. expect(results.formatString()).toEqual('a:a b:b c:c1 c:c2');
  197. results.addFilterValues('d', ['d']);
  198. expect(results.formatString()).toEqual('a:a b:b c:c1 c:c2 d:d');
  199. results.addFilterValues('e', ['e1*e2\\e3']);
  200. expect(results.formatString()).toEqual('a:a b:b c:c1 c:c2 d:d e:"e1\\*e2\\e3"');
  201. results.addStringFilter('d:d2');
  202. expect(results.formatString()).toEqual(
  203. 'a:a b:b c:c1 c:c2 d:d e:"e1\\*e2\\e3" d:d2'
  204. );
  205. });
  206. it('add text searches to query object', function () {
  207. const results = new MutableSearch(['a:a']);
  208. results.addFreeText('b');
  209. expect(results.formatString()).toEqual('a:a b');
  210. expect(results.freeText).toEqual(['b']);
  211. results.addFreeText('c');
  212. expect(results.formatString()).toEqual('a:a b c');
  213. expect(results.freeText).toEqual(['b', 'c']);
  214. results.addStringFilter('d:d').addFreeText('e');
  215. expect(results.formatString()).toEqual('a:a b c d:d e');
  216. expect(results.freeText).toEqual(['b', 'c', 'e']);
  217. results.freeText = ['x', 'y'];
  218. expect(results.formatString()).toEqual('a:a d:d x y');
  219. expect(results.freeText).toEqual(['x', 'y']);
  220. results.freeText = ['a b c'];
  221. expect(results.formatString()).toEqual('a:a d:d "a b c"');
  222. expect(results.freeText).toEqual(['a b c']);
  223. results.freeText = ['invalid literal for int() with base'];
  224. expect(results.formatString()).toEqual(
  225. 'a:a d:d "invalid literal for int() with base"'
  226. );
  227. expect(results.freeText).toEqual(['invalid literal for int() with base']);
  228. });
  229. it('add ops to query object', function () {
  230. const results = new MutableSearch(['x', 'a:a', 'y']);
  231. results.addOp('OR');
  232. expect(results.formatString()).toEqual('x a:a y OR');
  233. results.addFreeText('z');
  234. expect(results.formatString()).toEqual('x a:a y OR z');
  235. results
  236. .addOp('(')
  237. .addStringFilter('b:b')
  238. .addOp('AND')
  239. .addStringFilter('c:c')
  240. .addOp(')');
  241. expect(results.formatString()).toEqual('x a:a y OR z ( b:b AND c:c )');
  242. });
  243. it('adds tags to query', function () {
  244. const results = new MutableSearch(['tag:value']);
  245. results.addStringFilter('new:too');
  246. expect(results.formatString()).toEqual('tag:value new:too');
  247. });
  248. it('setTag() replaces tags', function () {
  249. const results = new MutableSearch(['tag:value']);
  250. results.setFilterValues('tag', ['too']);
  251. expect(results.formatString()).toEqual('tag:too');
  252. });
  253. it('setTag() replaces tags in OR', function () {
  254. let results = new MutableSearch([
  255. '(',
  256. 'transaction:xyz',
  257. 'OR',
  258. 'transaction:abc',
  259. ')',
  260. ]);
  261. results.setFilterValues('transaction', ['def']);
  262. expect(results.formatString()).toEqual('transaction:def');
  263. results = new MutableSearch(['(transaction:xyz', 'OR', 'transaction:abc)']);
  264. results.setFilterValues('transaction', ['def']);
  265. expect(results.formatString()).toEqual('transaction:def');
  266. });
  267. it('does not remove boolean operators after setting tag values', function () {
  268. const results = new MutableSearch([
  269. '(',
  270. 'start:xyz',
  271. 'AND',
  272. 'end:abc',
  273. ')',
  274. 'OR',
  275. '(',
  276. 'start:abc',
  277. 'AND',
  278. 'end:xyz',
  279. ')',
  280. ]);
  281. results.setFilterValues('transaction', ['def']);
  282. expect(results.formatString()).toEqual(
  283. '( start:xyz AND end:abc ) OR ( start:abc AND end:xyz ) transaction:def'
  284. );
  285. });
  286. it('removes tags from query object', function () {
  287. let results = new MutableSearch(['x', 'a:a', 'b:b']);
  288. results.removeFilter('a');
  289. expect(results.formatString()).toEqual('x b:b');
  290. results = new MutableSearch(['a:a']);
  291. results.removeFilter('a');
  292. expect(results.formatString()).toEqual('');
  293. results = new MutableSearch(['x', 'a:a', 'a:a2']);
  294. results.removeFilter('a');
  295. expect(results.formatString()).toEqual('x');
  296. results = new MutableSearch(['a:a', 'OR', 'b:b']);
  297. results.removeFilter('a');
  298. expect(results.formatString()).toEqual('b:b');
  299. results = new MutableSearch(['a:a', 'OR', 'a:a1', 'AND', 'b:b']);
  300. results.removeFilter('a');
  301. expect(results.formatString()).toEqual('b:b');
  302. results = new MutableSearch(['(a:a', 'OR', 'b:b)']);
  303. results.removeFilter('a');
  304. expect(results.formatString()).toEqual('b:b');
  305. results = new MutableSearch(['(a:a', 'OR', 'b:b', 'OR', 'y)']);
  306. results.removeFilter('a');
  307. expect(results.formatString()).toEqual('( b:b OR y )');
  308. results = new MutableSearch(['(a:a', 'OR', '(b:b1', 'OR', '(c:c', 'OR', 'b:b2)))']);
  309. results.removeFilter('b');
  310. expect(results.formatString()).toEqual('( a:a OR c:c )');
  311. results = new MutableSearch(['(((a:a', 'OR', 'b:b1)', 'OR', 'c:c)', 'OR', 'b:b2)']);
  312. results.removeFilter('b');
  313. expect(results.formatString()).toEqual('( ( a:a OR c:c ) )');
  314. });
  315. it('can return the tag keys', function () {
  316. const results = new MutableSearch(['tag:value', 'other:value', 'additional text']);
  317. expect(results.getFilterKeys()).toEqual(['tag', 'other']);
  318. });
  319. it('getTagValues', () => {
  320. const results = new MutableSearch([
  321. 'tag:value',
  322. 'other:value',
  323. 'tag:value2',
  324. 'additional text',
  325. ]);
  326. expect(results.getFilterValues('tag')).toEqual(['value', 'value2']);
  327. expect(results.getFilterValues('nonexistent')).toEqual([]);
  328. });
  329. });
  330. describe('QueryResults.formatString', function () {
  331. const cases = [
  332. {
  333. name: 'should convert a basic object to a query string',
  334. object: new MutableSearch(['is:unresolved']),
  335. string: 'is:unresolved',
  336. },
  337. {
  338. name: 'should quote tags with spaces',
  339. object: new MutableSearch(['is:unresolved', 'browser:"Chrome 36"']),
  340. string: 'is:unresolved browser:"Chrome 36"',
  341. },
  342. {
  343. name: 'should stringify the query',
  344. object: new MutableSearch(['python', 'is:unresolved', 'browser:"Chrome 36"']),
  345. string: 'python is:unresolved browser:"Chrome 36"',
  346. },
  347. {
  348. name: 'should join tokenized queries',
  349. object: new MutableSearch(['python', 'exception']),
  350. string: 'python exception',
  351. },
  352. {
  353. name: 'should quote tags with spaces',
  354. object: new MutableSearch([
  355. 'oh',
  356. 'me',
  357. 'oh',
  358. 'my',
  359. 'browser:"Chrome 36"',
  360. 'browser:"Firefox 60"',
  361. ]),
  362. string: 'oh me oh my browser:"Chrome 36" browser:"Firefox 60"',
  363. },
  364. {
  365. name: 'should quote tags with parens',
  366. object: new MutableSearch([
  367. 'bad',
  368. 'things',
  369. 'repository_id:"UUID(\'long-value\')"',
  370. ]),
  371. string: 'bad things repository_id:"UUID(\'long-value\')"',
  372. },
  373. {
  374. // values with quotes do not need to be quoted
  375. // furthermore, timestamps contain colons
  376. // but the backend currently does not support quoted date formats
  377. name: 'should not quote tags with colon',
  378. object: new MutableSearch(['bad', 'things', 'user:"id:123"']),
  379. string: 'bad things user:id:123',
  380. },
  381. {
  382. name: 'should escape quote tags with double quotes',
  383. object: new MutableSearch([
  384. 'bad',
  385. 'things',
  386. 'name:"Ernest \\"Papa\\" Hemingway"',
  387. ]),
  388. string: 'bad things name:"Ernest \\"Papa\\" Hemingway"',
  389. },
  390. {
  391. name: 'should include blank strings',
  392. object: new MutableSearch(['bad', 'things', 'name:""']),
  393. string: 'bad things name:""',
  394. },
  395. {
  396. name: 'correctly preserve boolean operators',
  397. object: new MutableSearch(['country:canada', 'OR', 'country:newzealand']),
  398. string: 'country:canada OR country:newzealand',
  399. },
  400. {
  401. name: 'correctly preserve parens',
  402. object: new MutableSearch([
  403. '(country:canada',
  404. 'OR',
  405. 'country:newzealand)',
  406. 'AND',
  407. 'province:pei',
  408. ]),
  409. string: '( country:canada OR country:newzealand ) AND province:pei',
  410. },
  411. {
  412. name: 'query tags boolean and parens are all stitched back together correctly',
  413. object: new MutableSearch([
  414. '(a:a',
  415. 'OR',
  416. '(b:b',
  417. 'AND',
  418. 'c',
  419. 'd',
  420. 'e))',
  421. 'OR',
  422. 'f',
  423. 'g:g',
  424. ]),
  425. string: '( a:a OR ( b:b AND c d e ) ) OR f g:g',
  426. },
  427. {
  428. name: 'correctly preserve filters with functions',
  429. object: new MutableSearch(['country:>canada', 'OR', 'coronaFree():<newzealand']),
  430. string: 'country:>canada OR coronaFree():<newzealand',
  431. },
  432. {
  433. name: 'should quote tags with parens and spaces',
  434. object: new MutableSearch(['release:4.9.0 build (0.0.01)', 'error.handled:0']),
  435. string: 'release:"4.9.0 build (0.0.01)" error.handled:0',
  436. },
  437. ];
  438. for (const {name, string, object} of cases) {
  439. // eslint-disable-next-line jest/valid-title
  440. it(name, () => expect(object.formatString()).toEqual(string));
  441. }
  442. });
  443. });