Просмотр исходного кода

feat(starfish): Use a custom SQL parser for SQL formatting (#53657)

Adds a new SQL parser and formatter, based on a custom PEG.js
SQL definition. The formatter has a very simple API:

```tsx
const formatter = new SQLishFormatter();
const output = formatter.toString(sql);
```
George Gritsouk 1 год назад
Родитель
Сommit
0b474ce91d

+ 6 - 1
static/app/views/starfish/components/spanDescription.tsx

@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
 
 import {CodeSnippet} from 'sentry/components/codeSnippet';
 import {SpanMetricsFields, SpanMetricsFieldTypes} from 'sentry/views/starfish/types';
+import {SQLishFormatter} from 'sentry/views/starfish/utils/sqlish/SQLishFormatter';
 
 type Props = {
   span: Pick<
@@ -19,8 +20,12 @@ export function SpanDescription({span}: Props) {
 }
 
 function DatabaseSpanDescription({span}: Props) {
+  const formatter = new SQLishFormatter();
+
   return (
-    <CodeSnippet language="sql">{span[SpanMetricsFields.SPAN_DESCRIPTION]}</CodeSnippet>
+    <CodeSnippet language="sql">
+      {formatter.toString(span[SpanMetricsFields.SPAN_DESCRIPTION])}
+    </CodeSnippet>
   );
 }
 

+ 0 - 103
static/app/views/starfish/utils/highlightSql.tsx

@@ -1,103 +0,0 @@
-import styled from '@emotion/styled';
-
-import {space} from 'sentry/styles/space';
-
-export const highlightSql = (
-  description: string,
-  queryDetail: {action: string; domain: string}
-) => {
-  let acc = '';
-  return description.split('').map((token, i) => {
-    acc += token;
-    let final: string | React.ReactElement | null = null;
-    if (acc === queryDetail.action) {
-      final = <Operation key={i}>{queryDetail.action} </Operation>;
-    } else if (acc === queryDetail.domain) {
-      final = <Domain key={i}>{queryDetail.domain} </Domain>;
-    } else if (KEYWORDS.has(acc)) {
-      final = <Keyword key={i}>{acc}</Keyword>;
-    } else if (['(', ')'].includes(acc)) {
-      final = <Bracket key={i}>{acc}</Bracket>;
-    } else if (token === ' ' || token === '\n' || description[i + 1] === ')') {
-      final = acc;
-    } else if (acc === '%s') {
-      final = <Parameter key={i}>{acc}</Parameter>;
-    } else if (i === description.length - 1) {
-      final = acc;
-    }
-    if (final) {
-      acc = '';
-      const result = final;
-      final = null;
-      return result;
-    }
-    return null;
-  });
-};
-
-const KEYWORDS = new Set([
-  'ADD',
-  'ALL',
-  'ALTER',
-  'COLUMN',
-  'TABLE',
-  'AND',
-  'ANY',
-  'AS',
-  'ASC',
-  'BACKUP',
-  'DATABASE',
-  'BETWEEN',
-  'CASE',
-  'CHECK',
-  'CONSTRAINT',
-  'CREATE',
-  'DATABASE',
-  'DEFAULT',
-  'DELETE',
-  'DESC',
-  'DISTINCT',
-  'DROP',
-  'EXEC',
-  'EXISTS',
-  'FOREIGN',
-  'KEY',
-  'FROM',
-  'FULL',
-  'OUTER',
-  'INNER',
-  'LEFT',
-  'RIGHT',
-  'ORDER',
-  'ON',
-  'LIMIT',
-  'WHERE',
-  'COUNT',
-  'JOIN',
-  'GROUP',
-  'BY',
-  'HAVING',
-  'UPDATE',
-]);
-
-const Operation = styled('b')`
-  color: ${p => p.theme.blue400};
-`;
-
-const Domain = styled('b')`
-  color: ${p => p.theme.green400};
-  margin-right: -${space(0.5)};
-`;
-
-const Parameter = styled('b')`
-  color: ${p => p.theme.red400};
-  margin-right: -${space(0.5)};
-`;
-
-const Keyword = styled('b')`
-  color: ${p => p.theme.yellow400};
-`;
-
-const Bracket = styled('b')`
-  color: ${p => p.theme.pink400};
-`;

+ 77 - 0
static/app/views/starfish/utils/sqlish/SQLishFormatter.spec.tsx

@@ -0,0 +1,77 @@
+import {SQLishFormatter} from 'sentry/views/starfish/utils/sqlish/SQLishFormatter';
+
+describe('SQLishFormatter', function () {
+  describe('SQLishFormatter.toString()', () => {
+    const formatter = new SQLishFormatter();
+
+    it('Falls back to original string if unable to parse', () => {
+      expect(formatter.toString('😤')).toEqual('😤');
+    });
+
+    it('Formats basic SQL', () => {
+      expect(formatter.toString('SELECT hello;')).toEqual('SELECT hello;');
+    });
+
+    it('Formats wildcards', () => {
+      expect(formatter.toString('SELECT *;')).toEqual('SELECT *;');
+    });
+
+    it('Formats equality', () => {
+      expect(formatter.toString('SELECT * FROM users WHERE age = 10;')).toEqual(
+        'SELECT * \nFROM users \nWHERE age = 10;'
+      );
+    });
+
+    it('Formats inequality', () => {
+      expect(formatter.toString('SELECT * FROM users WHERE age != 10;')).toEqual(
+        'SELECT * \nFROM users \nWHERE age != 10;'
+      );
+    });
+
+    it('Formats comparisons', () => {
+      expect(
+        formatter.toString('SELECT * FROM users WHERE age > 10 AND age < 20;')
+      ).toEqual('SELECT * \nFROM users \nWHERE age > 10 \nAND age < 20;');
+    });
+
+    it('Formats lists of bare column names', () => {
+      expect(formatter.toString('SELECT id, name;')).toEqual('SELECT id, name;');
+    });
+
+    it('Formats backtick columns', () => {
+      expect(formatter.toString('SELECT columns AS `tags[column]`)')).toEqual(
+        'SELECT columns AS `tags[column]`)'
+      );
+    });
+
+    it('Formats truncated queries', () => {
+      expect(formatter.toString('SELECT id, nam*')).toEqual('SELECT id, nam*');
+    });
+
+    it('Formats PHP-style parameters', () => {
+      expect(
+        formatter.toString(
+          'SELECT * FROM messages WHERE (receiver_user_id = Users.id AND created >= :c1))'
+        )
+      ).toEqual(
+        'SELECT * \nFROM messages \nWHERE (receiver_user_id = Users.id \nAND created >= :c1))'
+      );
+    });
+
+    it('Formats Python-style parameters', () => {
+      expect(
+        formatter.toString(
+          'SELECT * FROM messages WHERE (receiver_user_id = Users.id AND created >= %s))'
+        )
+      ).toEqual(
+        'SELECT * \nFROM messages \nWHERE (receiver_user_id = Users.id \nAND created >= %s))'
+      );
+    });
+
+    it('Adds newlines for keywords', () => {
+      expect(
+        formatter.toString('SELECT hello FROM users ORDER BY name DESC LIMIT 1;')
+      ).toEqual('SELECT hello \nFROM users \nORDER BY name DESC \nLIMIT 1;');
+    });
+  });
+});

+ 56 - 0
static/app/views/starfish/utils/sqlish/SQLishFormatter.tsx

@@ -0,0 +1,56 @@
+import * as Sentry from '@sentry/react';
+
+import {SQLishParser} from 'sentry/views/starfish/utils/sqlish/SQLishParser';
+import type {Token} from 'sentry/views/starfish/utils/sqlish/types';
+
+export class SQLishFormatter {
+  parser: SQLishParser;
+  tokens?: Token[];
+
+  constructor() {
+    this.parser = new SQLishParser();
+  }
+
+  toString(sql: string): string;
+  toString(tokens: Token[]): string;
+  toString(input: string | Token[]): string {
+    if (typeof input === 'string') {
+      try {
+        const tokens = this.parser.parse(input);
+        return this.toString(tokens);
+      } catch (error) {
+        Sentry.captureException(error);
+        // If we fail to parse the SQL, return the original string, so there is always output
+        return input;
+      }
+    }
+
+    const tokens = input;
+    let ret = '';
+
+    function contentize(content: Token): void {
+      if (content.type === 'Keyword') {
+        ret += '\n';
+      }
+
+      if (Array.isArray(content.content)) {
+        content.content.forEach(contentize);
+        return;
+      }
+
+      if (typeof content.content === 'string') {
+        if (content.type === 'Whitespace') {
+          ret += ' ';
+        } else {
+          ret += content.content;
+        }
+        return;
+      }
+
+      return;
+    }
+
+    tokens.forEach(contentize);
+    return ret.trim();
+  }
+}

+ 11 - 0
static/app/views/starfish/utils/sqlish/SQLishParser.ts

@@ -0,0 +1,11 @@
+import type {Token} from 'sentry/views/starfish/utils/sqlish/types';
+
+import grammar from './sqlish.pegjs';
+
+export class SQLishParser {
+  constructor() {}
+
+  parse(sql: string) {
+    return grammar.parse(sql) as Token[];
+  }
+}

+ 33 - 0
static/app/views/starfish/utils/sqlish/sqlish.pegjs

@@ -0,0 +1,33 @@
+Expression
+   = tokens:Token*
+
+Token
+   = Keyword / Parameter / CollapsedColumns / Whitespace / GenericToken
+
+Keyword
+  = Keyword:("SELECT"i / "INSERT"i / "DELETE"i / "FROM"i / "ON"i / "WHERE"i / "AND"i / "ORDER BY"i / "LIMIT"i / "GROUP BY"i / "OFFSET"i / JoinKeyword) {
+  return { type: 'Keyword', content: Keyword }
+}
+
+JoinKeyword
+  = JoinDirection:JoinDirection? Whitespace JoinType:JoinType? Whitespace "JOIN"i {
+  return (JoinDirection || '') + (JoinDirection ? " " : '') + JoinType + " " + "JOIN"
+}
+
+JoinDirection
+ = "LEFT"i / "RIGHT"i / "FULL"i
+
+JoinType
+= "OUTER"i / "INNER"i
+
+Parameter
+  = Parameter:("%s" / ":c" [0-9]) { return { type: 'Parameter', content: Array.isArray(Parameter) ? Parameter.join('') : Parameter } }
+
+CollapsedColumns
+  = ".." { return { type: 'CollapsedColumns' } }
+
+Whitespace
+  = Whitespace:[\n\t ]+ { return { type: 'Whitespace', content: Whitespace.join("") } }
+
+GenericToken
+  = GenericToken:[a-zA-Z0-9"'`_\-.()=><:,*;!\[\]?]+ { return { type: 'GenericToken', content: GenericToken.join('') } }

+ 4 - 0
static/app/views/starfish/utils/sqlish/types.ts

@@ -0,0 +1,4 @@
+export interface Token {
+  type: 'Keyword' | 'Parameter' | 'CollapsedColumns' | 'Whitespace' | 'GenericToken';
+  content?: string | Token | Token[];
+}