parser.spec.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import {parse} from 'query-string';
  2. import {loadFixtures} from 'sentry-test/loadFixtures';
  3. import type {
  4. ParseResult,
  5. SearchConfig,
  6. TokenResult,
  7. } from 'sentry/components/searchSyntax/parser';
  8. import {
  9. BooleanOperator,
  10. InvalidReason,
  11. parseSearch,
  12. Token,
  13. } from 'sentry/components/searchSyntax/parser';
  14. import {treeTransformer} from 'sentry/components/searchSyntax/utils';
  15. type TestCase = {
  16. /**
  17. * Additional parser configuration
  18. */
  19. additionalConfig: Parameters<typeof parseSearch>[1];
  20. /**
  21. * The search query string under parsing test
  22. */
  23. query: string;
  24. /**
  25. * The expected result for the query
  26. */
  27. result: ParseResult;
  28. /**
  29. * This is set when the query is expected to completely fail to parse.
  30. */
  31. raisesError?: boolean;
  32. };
  33. /**
  34. * Normalize results to match the json test cases
  35. */
  36. const normalizeResult = (tokens: TokenResult<Token>[]) =>
  37. treeTransformer({
  38. tree: tokens,
  39. transform: token => {
  40. // XXX: This attempts to keep the test data simple, only including keys
  41. // that are really needed to validate functionality.
  42. // @ts-expect-error
  43. delete token.location;
  44. // @ts-expect-error
  45. delete token.text;
  46. // @ts-expect-error
  47. delete token.config;
  48. if (!parse) {
  49. // @ts-expect-error
  50. delete token.parsed;
  51. }
  52. // token warnings only exist in the FE atm
  53. // @ts-expect-error
  54. delete token.warning;
  55. if (token.type === Token.FILTER && token.invalid === null) {
  56. // @ts-expect-error
  57. delete token.invalid;
  58. }
  59. if (
  60. token.type === Token.VALUE_ISO_8601_DATE ||
  61. token.type === Token.VALUE_RELATIVE_DATE
  62. ) {
  63. if (token.parsed?.value instanceof Date) {
  64. // @ts-expect-error we cannot have dates in JSON
  65. token.parsed.value = token.parsed.value.toISOString();
  66. }
  67. }
  68. return token;
  69. },
  70. });
  71. describe('searchSyntax/parser', function () {
  72. const testData = loadFixtures('search-syntax') as unknown as Record<string, TestCase[]>;
  73. const registerTestCase = (
  74. testCase: TestCase,
  75. additionalConfig: Partial<SearchConfig> = {}
  76. ) =>
  77. it(`handles ${testCase.query}`, () => {
  78. const result = parseSearch(testCase.query, {
  79. ...testCase.additionalConfig,
  80. ...additionalConfig,
  81. });
  82. // Handle errors
  83. if (testCase.raisesError) {
  84. expect(result).toBeNull();
  85. return;
  86. }
  87. if (result === null) {
  88. throw new Error('Parsed result as null without raiseError true');
  89. }
  90. expect(normalizeResult(result)).toEqual(testCase.result);
  91. });
  92. Object.entries(testData).map(([name, cases]) =>
  93. describe(`${name}`, () => {
  94. cases.map(c => registerTestCase(c, {parse: true}));
  95. })
  96. );
  97. it('returns token warnings', () => {
  98. const result = parseSearch('foo:bar bar:baz tags[foo]:bar tags[bar]:baz', {
  99. getFilterTokenWarning: key => (key === 'foo' ? 'foo warning' : null),
  100. });
  101. // check with error to satisfy type checker
  102. if (result === null) {
  103. throw new Error('Parsed result as null');
  104. }
  105. expect(result).toHaveLength(9);
  106. const foo = result[1] as TokenResult<Token.FILTER>;
  107. const bar = result[3] as TokenResult<Token.FILTER>;
  108. const fooTag = result[5] as TokenResult<Token.FILTER>;
  109. const barTag = result[7] as TokenResult<Token.FILTER>;
  110. expect(foo.warning).toBe('foo warning');
  111. expect(bar.warning).toBe(null);
  112. expect(fooTag.warning).toBe('foo warning');
  113. expect(barTag.warning).toBe(null);
  114. });
  115. it('applies disallowFreeText', () => {
  116. const result = parseSearch('foo:bar test', {
  117. disallowFreeText: true,
  118. invalidMessages: {
  119. [InvalidReason.FREE_TEXT_NOT_ALLOWED]: 'Custom message',
  120. },
  121. });
  122. // check with error to satisfy type checker
  123. if (result === null) {
  124. throw new Error('Parsed result as null');
  125. }
  126. expect(result).toHaveLength(5);
  127. const foo = result[1] as TokenResult<Token.FILTER>;
  128. const test = result[3] as TokenResult<Token.FREE_TEXT>;
  129. expect(foo.invalid).toBe(null);
  130. expect(test.invalid).toEqual({
  131. type: InvalidReason.FREE_TEXT_NOT_ALLOWED,
  132. reason: 'Custom message',
  133. });
  134. });
  135. it('applies disallowLogicalOperators (OR)', () => {
  136. const result = parseSearch('foo:bar OR AND', {
  137. disallowedLogicalOperators: new Set([BooleanOperator.OR]),
  138. invalidMessages: {
  139. [InvalidReason.LOGICAL_OR_NOT_ALLOWED]: 'Custom message',
  140. },
  141. });
  142. // check with error to satisfy type checker
  143. if (result === null) {
  144. throw new Error('Parsed result as null');
  145. }
  146. expect(result).toHaveLength(7);
  147. const foo = result[1] as TokenResult<Token.FILTER>;
  148. const or = result[3] as TokenResult<Token.LOGIC_BOOLEAN>;
  149. const and = result[5] as TokenResult<Token.LOGIC_BOOLEAN>;
  150. expect(foo.invalid).toBe(null);
  151. expect(or.invalid).toEqual({
  152. type: InvalidReason.LOGICAL_OR_NOT_ALLOWED,
  153. reason: 'Custom message',
  154. });
  155. expect(and.invalid).toBe(null);
  156. });
  157. it('applies disallowLogicalOperators (AND)', () => {
  158. const result = parseSearch('foo:bar OR AND', {
  159. disallowedLogicalOperators: new Set([BooleanOperator.AND]),
  160. invalidMessages: {
  161. [InvalidReason.LOGICAL_AND_NOT_ALLOWED]: 'Custom message',
  162. },
  163. });
  164. // check with error to satisfy type checker
  165. if (result === null) {
  166. throw new Error('Parsed result as null');
  167. }
  168. expect(result).toHaveLength(7);
  169. const foo = result[1] as TokenResult<Token.FILTER>;
  170. const or = result[3] as TokenResult<Token.LOGIC_BOOLEAN>;
  171. const and = result[5] as TokenResult<Token.LOGIC_BOOLEAN>;
  172. expect(foo.invalid).toBe(null);
  173. expect(or.invalid).toBe(null);
  174. expect(and.invalid).toEqual({
  175. type: InvalidReason.LOGICAL_AND_NOT_ALLOWED,
  176. reason: 'Custom message',
  177. });
  178. });
  179. describe('flattenParenGroups', () => {
  180. it('tokenizes mismatched parens with flattenParenGroups=true', () => {
  181. const result = parseSearch('foo(', {
  182. flattenParenGroups: true,
  183. });
  184. if (result === null) {
  185. throw new Error('Parsed result as null');
  186. }
  187. // foo( is parsed as free text a single paren
  188. expect(result).toEqual([
  189. expect.objectContaining({type: Token.SPACES}),
  190. expect.objectContaining({type: Token.FREE_TEXT}),
  191. expect.objectContaining({type: Token.SPACES}),
  192. expect.objectContaining({
  193. type: Token.L_PAREN,
  194. value: '(',
  195. }),
  196. expect.objectContaining({type: Token.SPACES}),
  197. ]);
  198. });
  199. it('tokenizes matching parens with flattenParenGroups=true', () => {
  200. const result = parseSearch('(foo)', {
  201. flattenParenGroups: true,
  202. });
  203. if (result === null) {
  204. throw new Error('Parsed result as null');
  205. }
  206. // (foo) is parsed as free text and two parens
  207. expect(result).toEqual([
  208. expect.objectContaining({type: Token.SPACES}),
  209. expect.objectContaining({
  210. type: Token.L_PAREN,
  211. value: '(',
  212. }),
  213. expect.objectContaining({type: Token.SPACES}),
  214. expect.objectContaining({type: Token.FREE_TEXT}),
  215. expect.objectContaining({type: Token.SPACES}),
  216. expect.objectContaining({
  217. type: Token.R_PAREN,
  218. value: ')',
  219. }),
  220. expect.objectContaining({type: Token.SPACES}),
  221. ]);
  222. });
  223. it('tokenizes mismatched left paren with flattenParenGroups=false', () => {
  224. const result = parseSearch('foo(', {
  225. flattenParenGroups: false,
  226. });
  227. if (result === null) {
  228. throw new Error('Parsed result as null');
  229. }
  230. // foo( is parsed as free text and a paren
  231. expect(result).toEqual([
  232. expect.objectContaining({type: Token.SPACES}),
  233. expect.objectContaining({type: Token.FREE_TEXT}),
  234. expect.objectContaining({type: Token.SPACES}),
  235. expect.objectContaining({
  236. type: Token.L_PAREN,
  237. value: '(',
  238. }),
  239. expect.objectContaining({type: Token.SPACES}),
  240. ]);
  241. });
  242. it('tokenizes mismatched right paren with flattenParenGroups=false', () => {
  243. const result = parseSearch('foo)', {
  244. flattenParenGroups: false,
  245. });
  246. if (result === null) {
  247. throw new Error('Parsed result as null');
  248. }
  249. // foo( is parsed as free text and a paren
  250. expect(result).toEqual([
  251. expect.objectContaining({type: Token.SPACES}),
  252. expect.objectContaining({type: Token.FREE_TEXT}),
  253. expect.objectContaining({type: Token.SPACES}),
  254. expect.objectContaining({
  255. type: Token.R_PAREN,
  256. value: ')',
  257. }),
  258. expect.objectContaining({type: Token.SPACES}),
  259. ]);
  260. });
  261. it('parses matching parens as logic group with flattenParenGroups=false', () => {
  262. const result = parseSearch('(foo)', {
  263. flattenParenGroups: false,
  264. });
  265. if (result === null) {
  266. throw new Error('Parsed result as null');
  267. }
  268. // (foo) is parsed as a logic group
  269. expect(result).toEqual([
  270. expect.objectContaining({type: Token.SPACES}),
  271. expect.objectContaining({type: Token.LOGIC_GROUP}),
  272. expect.objectContaining({type: Token.SPACES}),
  273. ]);
  274. });
  275. it('tokenizes empty matched parens and flattenParenGroups=false', () => {
  276. const result = parseSearch('()', {
  277. flattenParenGroups: false,
  278. });
  279. if (result === null) {
  280. throw new Error('Parsed result as null');
  281. }
  282. expect(result).toEqual([
  283. expect.objectContaining({type: Token.SPACES}),
  284. expect.objectContaining({
  285. type: Token.LOGIC_GROUP,
  286. inner: [expect.objectContaining({type: Token.SPACES})],
  287. }),
  288. expect.objectContaining({type: Token.SPACES}),
  289. ]);
  290. });
  291. });
  292. });