Repository: sql-formatter-org/sql-formatter
Pull request #426 (2022-09-04 16:09:48)

Overhaul handling of single-char operators
Instead of supporting all possible single-char operators in all dialects we're now only supporting them in the dialects that support them.

Also separated operators lists in tests from operators lists in .formatter.ts files. This adds some duplication, but makes the tests ultimately more useful.


Patch:
diff --git a/src/languages/bigquery/bigquery.formatter.ts b/src/languages/bigquery/bigquery.formatter.ts
index 71d0ff60..109d5057 100644
--- a/src/languages/bigquery/bigquery.formatter.ts
+++ b/src/languages/bigquery/bigquery.formatter.ts
@@ -140,9 +140,7 @@ const reservedPhrases = expandPhrases([
 
 // https://cloud.google.com/bigquery/docs/reference/#standard-sql-reference
 export default class BigQueryFormatter extends Formatter {
-  static operators = ['~', '>>', '<<', '||'];
   // TODO: handle trailing comma in select clause
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -168,7 +166,7 @@ export default class BigQueryFormatter extends Formatter {
       identChars: { dashes: true },
       paramTypes: { positional: true, named: ['@'], quoted: ['@'] },
       lineCommentTypes: ['--', '#'],
-      operators: BigQueryFormatter.operators,
+      operators: ['&', '|', '^', '~', '>>', '<<', '||'],
       postProcess,
     });
   }
diff --git a/src/languages/db2/db2.formatter.ts b/src/languages/db2/db2.formatter.ts
index b19b9126..50f615ef 100644
--- a/src/languages/db2/db2.formatter.ts
+++ b/src/languages/db2/db2.formatter.ts
@@ -170,8 +170,6 @@ const reservedPhrases = expandPhrases(['ON DELETE', 'ON UPDATE', '{ROWS | RANGE}
 
 // https://www.ibm.com/support/knowledgecenter/en/ssw_ibm_i_72/db2/rbafzintro.htm
 export default class Db2Formatter extends Formatter {
-  static operators = ['**', '¬=', '¬>', '¬<', '!>', '!<', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -189,7 +187,7 @@ export default class Db2Formatter extends Formatter {
       identTypes: [`""-qq`],
       paramTypes: { positional: true, named: [':'] },
       paramChars: { first: '@#$', rest: '@#$' },
-      operators: Db2Formatter.operators,
+      operators: ['**', '¬=', '¬>', '¬<', '!>', '!<', '||'],
     });
   }
 }
diff --git a/src/languages/hive/hive.formatter.ts b/src/languages/hive/hive.formatter.ts
index 47efc4c8..4a6f432a 100644
--- a/src/languages/hive/hive.formatter.ts
+++ b/src/languages/hive/hive.formatter.ts
@@ -82,8 +82,6 @@ const reservedPhrases = expandPhrases(['{ROWS | RANGE} BETWEEN']);
 
 // https://cwiki.apache.org/confluence/display/Hive/LanguageManual
 export default class HiveFormatter extends Formatter {
-  static operators = ['<=>', '==', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -98,7 +96,7 @@ export default class HiveFormatter extends Formatter {
       stringTypes: ['""-bs', "''-bs"],
       identTypes: ['``'],
       variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
-      operators: HiveFormatter.operators,
+      operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||'],
     });
   }
 }
diff --git a/src/languages/mariadb/mariadb.formatter.ts b/src/languages/mariadb/mariadb.formatter.ts
index 238d0edf..10eecd6e 100644
--- a/src/languages/mariadb/mariadb.formatter.ts
+++ b/src/languages/mariadb/mariadb.formatter.ts
@@ -262,8 +262,6 @@ const reservedPhrases = expandPhrases([
 
 // For reference: https://mariadb.com/kb/en/sql-statements-structure/
 export default class MariaDbFormatter extends Formatter {
-  static operators = [':=', '<<', '>>', '<=>', '&&', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -291,7 +289,7 @@ export default class MariaDbFormatter extends Formatter {
       ],
       paramTypes: { positional: true },
       lineCommentTypes: ['--', '#'],
-      operators: MariaDbFormatter.operators,
+      operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||', '!'],
       postProcess,
     });
   }
diff --git a/src/languages/mysql/mysql.formatter.ts b/src/languages/mysql/mysql.formatter.ts
index 3b2ea41c..29994118 100644
--- a/src/languages/mysql/mysql.formatter.ts
+++ b/src/languages/mysql/mysql.formatter.ts
@@ -230,8 +230,6 @@ const reservedPhrases = expandPhrases([
 
 // https://dev.mysql.com/doc/refman/8.0/en/
 export default class MySqlFormatter extends Formatter {
-  static operators = ['~', ':=', '<<', '>>', '<=>', '&&', '||', '->', '->>'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -259,7 +257,7 @@ export default class MySqlFormatter extends Formatter {
       ],
       paramTypes: { positional: true },
       lineCommentTypes: ['--', '#'],
-      operators: MySqlFormatter.operators,
+      operators: ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '->', '->>', '&&', '||', '!'],
       postProcess,
     });
   }
diff --git a/src/languages/n1ql/n1ql.formatter.ts b/src/languages/n1ql/n1ql.formatter.ts
index 8de35613..67fb900e 100644
--- a/src/languages/n1ql/n1ql.formatter.ts
+++ b/src/languages/n1ql/n1ql.formatter.ts
@@ -79,8 +79,6 @@ const reservedPhrases = expandPhrases(['{ROWS | RANGE | GROUPS} BETWEEN']);
 
 // For reference: http://docs.couchbase.com.s3-website-us-west-1.amazonaws.com/server/6.0/n1ql/n1ql-language-reference/index.html
 export default class N1qlFormatter extends Formatter {
-  static operators = ['==', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -100,7 +98,7 @@ export default class N1qlFormatter extends Formatter {
       extraParens: ['[]', '{}'],
       paramTypes: { positional: true, numbered: ['$'], named: ['$'] },
       lineCommentTypes: ['#', '--'],
-      operators: N1qlFormatter.operators,
+      operators: ['%', '==', ':', '||'],
     });
   }
 }
diff --git a/src/languages/plsql/plsql.formatter.ts b/src/languages/plsql/plsql.formatter.ts
index 62bb902a..35bf443d 100644
--- a/src/languages/plsql/plsql.formatter.ts
+++ b/src/languages/plsql/plsql.formatter.ts
@@ -79,18 +79,6 @@ const reservedPhrases = expandPhrases([
 ]);
 
 export default class PlSqlFormatter extends Formatter {
-  static operators = [
-    '||',
-    '**',
-    ':=',
-    '~=',
-    '^=',
-    '>>',
-    '<<',
-    '=>',
-    //  '..' // breaks operator test, handled by .
-  ];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -114,7 +102,19 @@ export default class PlSqlFormatter extends Formatter {
       variableTypes: [{ regex: '&{1,2}[A-Za-z][A-Za-z0-9_$#]*' }],
       paramTypes: { numbered: [':'], named: [':'] },
       paramChars: {}, // Empty object used on purpose to not allow $ and # chars as specified in identChars
-      operators: PlSqlFormatter.operators,
+      operators: [
+        '**',
+        ':=',
+        '%',
+        '~=',
+        '^=',
+        // '..', // Conflicts with float followed by dot (so "2..3" gets parsed as ["2.", ".", "3"])
+        '>>',
+        '<<',
+        '=>',
+        '@',
+        '||',
+      ],
       postProcess,
     });
   }
diff --git a/src/languages/postgresql/postgresql.formatter.ts b/src/languages/postgresql/postgresql.formatter.ts
index 8756b25c..207a2b57 100644
--- a/src/languages/postgresql/postgresql.formatter.ts
+++ b/src/languages/postgresql/postgresql.formatter.ts
@@ -245,83 +245,8 @@ const reservedPhrases = expandPhrases([
   '{TIMESTAMP | TIME} {WITH | WITHOUT} TIME ZONE',
 ]);
 
-const binaryOperators = [
-  // Math Operators
-  '<<',
-  '>>',
-  '|/',
-  '||/',
-  '!!',
-  // String Operators
-  '||',
-  // Pattern Matching Operators
-  '~~',
-  '~~*',
-  '!~~',
-  '!~~*',
-  // POSIX RegExp operators
-  '~',
-  '~*',
-  '!~',
-  '!~*',
-  // Similarity Operators
-  '<%',
-  '<<%',
-  '%>',
-  '%>>',
-  // Byte Comparison Operators
-  '~>~',
-  '~<~',
-  '~>=~',
-  '~<=~',
-  // Geometric operators
-  '@-@',
-  '@@',
-  '#',
-  '##',
-  '<->',
-  '&&',
-  '&<',
-  '&>',
-  '<<|',
-  '&<|',
-  '|>>',
-  '|&>',
-  '<^',
-  '^>',
-  '?#',
-  '?-',
-  '?|',
-  '?-|',
-  '?||',
-  '@>',
-  '<@',
-  '~=',
-  // Network Address operators
-  '>>=',
-  '<<=',
-  // Text Search Operators
-  '@@@',
-  // JSON Operators
-  '?',
-  '@?',
-  '?&',
-  '->',
-  '->>',
-  '#>',
-  '#>>',
-  '#-',
-  // Other Operators
-  ':=',
-  '::',
-  '=>',
-  '-|-',
-];
-
 // https://www.postgresql.org/docs/14/index.html
 export default class PostgreSqlFormatter extends Formatter {
-  static operators = binaryOperators;
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -343,7 +268,92 @@ export default class PostgreSqlFormatter extends Formatter {
       identTypes: [{ quote: '""-qq', prefixes: ['U&'] }],
       identChars: { rest: '$' },
       paramTypes: { numbered: ['$'] },
-      operators: PostgreSqlFormatter.operators,
+      operators: [
+        // Arithmetic
+        '%',
+        '^',
+        '|/',
+        '||/',
+        '@',
+        // Assignment
+        ':=',
+        // Bitwise
+        '&',
+        '|',
+        '#',
+        '~',
+        '<<',
+        '>>',
+        // Byte comparison
+        '~>~',
+        '~<~',
+        '~>=~',
+        '~<=~',
+        // Geometric
+        '@-@',
+        '@@',
+        '##',
+        '<->',
+        '&&',
+        '&<',
+        '&>',
+        '<<|',
+        '&<|',
+        '|>>',
+        '|&>',
+        '<^',
+        '^>',
+        '?#',
+        '?-',
+        '?|',
+        '?-|',
+        '?||',
+        '@>',
+        '<@',
+        '~=',
+        // JSON
+        '?',
+        '@?',
+        '?&',
+        '->',
+        '->>',
+        '#>',
+        '#>>',
+        '#-',
+        // Named function params
+        '=>',
+        // Network address
+        '>>=',
+        '<<=',
+        // Pattern matching
+        '~~',
+        '~~*',
+        '!~~',
+        '!~~*',
+        // POSIX RegExp
+        '~',
+        '~*',
+        '!~',
+        '!~*',
+        // Range/multirange
+        '-|-',
+        // String concatenation
+        '||',
+        // Text search
+        '@@@',
+        '!!',
+        // Trigram/trigraph
+        '<%',
+        '%>',
+        '<<%',
+        '%>>',
+        '<<->',
+        '<->>',
+        '<<<->',
+        '<->>>',
+        // Type cast
+        '::',
+      ],
     });
   }
 }
diff --git a/src/languages/redshift/redshift.formatter.ts b/src/languages/redshift/redshift.formatter.ts
index b8d0358e..149219a9 100644
--- a/src/languages/redshift/redshift.formatter.ts
+++ b/src/languages/redshift/redshift.formatter.ts
@@ -140,8 +140,6 @@ const reservedPhrases = expandPhrases([
 
 // https://docs.aws.amazon.com/redshift/latest/dg/cm_chap_SQLCommandRef.html
 export default class RedshiftFormatter extends Formatter {
-  static operators = ['~', '|/', '||/', '<<', '>>', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -156,7 +154,20 @@ export default class RedshiftFormatter extends Formatter {
       identTypes: [`""-qq`],
       identChars: { first: '#' },
       paramTypes: { numbered: ['$'] },
-      operators: RedshiftFormatter.operators,
+      operators: [
+        '^',
+        '%',
+        '@',
+        '|/',
+        '||/',
+        '&',
+        '|',
+        // '#', conflicts with first char of identifier
+        '~',
+        '<<',
+        '>>',
+        '||',
+      ],
     });
   }
 }
diff --git a/src/languages/singlestoredb/singlestoredb.formatter.ts b/src/languages/singlestoredb/singlestoredb.formatter.ts
index 76286d5c..5259e457 100644
--- a/src/languages/singlestoredb/singlestoredb.formatter.ts
+++ b/src/languages/singlestoredb/singlestoredb.formatter.ts
@@ -231,8 +231,6 @@ const reservedPhrases = expandPhrases([
 ]);
 
 export default class SingleStoreDbFormatter extends Formatter {
-  static operators = ['~', ':=', '<=>', '<<', '>>', '&&', '||'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -256,7 +254,7 @@ export default class SingleStoreDbFormatter extends Formatter {
         { quote: '``', prefixes: ['@'], requirePrefix: true },
       ],
       lineCommentTypes: ['--', '#'],
-      operators: SingleStoreDbFormatter.operators,
+      operators: [':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||'],
       postProcess,
     });
   }
diff --git a/src/languages/spark/spark.formatter.ts b/src/languages/spark/spark.formatter.ts
index d8940310..85fa7529 100644
--- a/src/languages/spark/spark.formatter.ts
+++ b/src/languages/spark/spark.formatter.ts
@@ -117,8 +117,6 @@ const reservedPhrases = expandPhrases([
 
 // http://spark.apache.org/docs/latest/sql-programming-guide.html
 export default class SparkFormatter extends Formatter {
-  static operators = ['~', '<=>', '&&', '||', '==', '->'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -139,7 +137,7 @@ export default class SparkFormatter extends Formatter {
       ],
       identTypes: ['``'],
       variableTypes: [{ quote: '{}', prefixes: ['$'], requirePrefix: true }],
-      operators: SparkFormatter.operators,
+      operators: ['%', '~', '^', '|', '&', '<=>', '==', '!', '||', '->'],
       postProcess,
     });
   }
diff --git a/src/languages/sql/sql.formatter.ts b/src/languages/sql/sql.formatter.ts
index 7560961d..6af12369 100644
--- a/src/languages/sql/sql.formatter.ts
+++ b/src/languages/sql/sql.formatter.ts
@@ -68,8 +68,6 @@ const reservedJoins = expandPhrases([
 const reservedPhrases = expandPhrases(['ON DELETE', 'ON UPDATE', '{ROWS | RANGE} BETWEEN']);
 
 export default class SqlFormatter extends Formatter {
-  static operators = [];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
diff --git a/src/languages/sqlite/sqlite.formatter.ts b/src/languages/sqlite/sqlite.formatter.ts
index abc7e100..bd9486fa 100644
--- a/src/languages/sqlite/sqlite.formatter.ts
+++ b/src/languages/sqlite/sqlite.formatter.ts
@@ -61,9 +61,6 @@ const reservedPhrases = expandPhrases([
 ]);
 
 export default class SqliteFormatter extends Formatter {
-  // https://www.sqlite.org/lang_expr.html
-  static operators = ['~', '->', '->>', '||', '<<', '>>', '=='];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -83,7 +80,7 @@ export default class SqliteFormatter extends Formatter {
       identTypes: [`""-qq`, '``', '[]'],
       // https://www.sqlite.org/lang_expr.html#parameters
       paramTypes: { positional: true, numbered: ['?'], named: [':', '@', '$'] },
-      operators: SqliteFormatter.operators,
+      operators: ['%', '~', '&', '|', '<<', '>>', '==', '->', '->>', '||'],
     });
   }
 }
diff --git a/src/languages/trino/trino.formatter.ts b/src/languages/trino/trino.formatter.ts
index 172659db..62df2997 100644
--- a/src/languages/trino/trino.formatter.ts
+++ b/src/languages/trino/trino.formatter.ts
@@ -120,10 +120,6 @@ const reservedJoins = expandPhrases([
 const reservedPhrases = expandPhrases(['{ROWS | RANGE | GROUPS} BETWEEN']);
 
 export default class TrinoFormatter extends Formatter {
-  // https://trino.io/docs/current/functions/list.html#id1
-  // https://trino.io/docs/current/sql/match-recognize.html#row-pattern-syntax
-  static operators = ['||', '->'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -148,7 +144,17 @@ export default class TrinoFormatter extends Formatter {
       // https://trino.io/docs/current/language/reserved.html
       identTypes: ['""-qq'],
       paramTypes: { positional: true },
-      operators: TrinoFormatter.operators,
+      operators: [
+        '%',
+        '->',
+        ':',
+        '||',
+        // Row pattern syntax
+        '|',
+        '^',
+        '$',
+        // '?', conflicts with positional placeholders
+      ],
     });
   }
 }
diff --git a/src/languages/tsql/tsql.formatter.ts b/src/languages/tsql/tsql.formatter.ts
index 5333785d..0ecb4fba 100644
--- a/src/languages/tsql/tsql.formatter.ts
+++ b/src/languages/tsql/tsql.formatter.ts
@@ -214,8 +214,6 @@ const reservedPhrases = expandPhrases(['ON DELETE', 'ON UPDATE', '{ROWS | RANGE}
 
 // https://docs.microsoft.com/en-us/sql/t-sql/language-reference?view=sql-server-ver15
 export default class TSqlFormatter extends Formatter {
-  static operators = ['~', '!<', '!>', '+=', '-=', '*=', '/=', '%=', '|=', '&=', '^=', '::'];
-
   tokenizer() {
     return new Tokenizer({
       reservedCommands,
@@ -231,7 +229,24 @@ export default class TSqlFormatter extends Formatter {
       identTypes: [`""-qq`, '[]'],
       identChars: { first: '#@', rest: '#@$' },
       paramTypes: { named: ['@'], quoted: ['@'] },
-      operators: TSqlFormatter.operators,
+      operators: [
+        '%',
+        '&',
+        '|',
+        '^',
+        '~',
+        '!<',
+        '!>',
+        '+=',
+        '-=',
+        '*=',
+        '/=',
+        '%=',
+        '|=',
+        '&=',
+        '^=',
+        '::',
+      ],
       // TODO: Support for money constants
     });
   }
diff --git a/src/lexer/Tokenizer.ts b/src/lexer/Tokenizer.ts
index 49e3f99b..691e14fc 100644
--- a/src/lexer/Tokenizer.ts
+++ b/src/lexer/Tokenizer.ts
@@ -153,7 +153,14 @@ export default class Tokenizer {
       },
       {
         type: TokenType.OPERATOR,
-        regex: regex.operator('-+/%&|^><=:$@#?~!', [
+        regex: regex.operator([
+          // standard operators
+          '+',
+          '-',
+          '/',
+          '>',
+          '<',
+          '=',
           '<>',
           '<=',
           '>=',
diff --git a/src/lexer/regexFactory.ts b/src/lexer/regexFactory.ts
index 7f7bb508..c6d60e97 100644
--- a/src/lexer/regexFactory.ts
+++ b/src/lexer/regexFactory.ts
@@ -21,14 +21,9 @@ export const parenthesis = (kind: 'open' | 'close', extraParens: ('[]' | '{}')[]
 
 /**
  * Builds a RegExp containing all operators for a SQL dialect
- * @param {string} monadOperators - concatenated string of all 1-length operators
- * @param {string[]} polyadOperators - list of strings of all >1-length operators
  */
-export const operator = (monadOperators: string, polyadOperators: string[]) =>
-  patternToRegex(
-    `${sortByLengthDesc(polyadOperators).map(escapeRegExp).join('|')}|` +
-      `[${monadOperators.split('').map(escapeRegExp).join('')}]`
-  );
+export const operator = (operators: string[]) =>
+  patternToRegex(`${sortByLengthDesc(operators).map(escapeRegExp).join('|')}`);
 
 // Negative lookahead to avoid matching a keyword that's actually part of identifier,
 // which can happen when identifier allows word-boundary characters inside it.


Test patch:
diff --git a/test/bigquery.test.ts b/test/bigquery.test.ts
index 9c8d699b..faf25232 100644
--- a/test/bigquery.test.ts
+++ b/test/bigquery.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import BigQueryFormatter from 'src/languages/bigquery/bigquery.formatter';
 import { flatKeywordList } from 'src/utils';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
@@ -56,7 +55,7 @@ describe('BigQueryFormatter', () => {
     'EXCEPT DISTINCT',
     'INTERSECT DISTINCT',
   ]);
-  supportsOperators(format, BigQueryFormatter.operators);
+  supportsOperators(format, ['&', '|', '^', '~', '>>', '<<', '||']);
   supportsParams(format, { positional: true, named: ['@'], quoted: ['@``'] });
   supportsWindow(format);
   supportsLimiting(format, { limit: true, offset: true });
diff --git a/test/db2.test.ts b/test/db2.test.ts
index dfe41a63..98e87b16 100644
--- a/test/db2.test.ts
+++ b/test/db2.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import Db2Formatter from 'src/languages/db2/db2.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsAlterTable from './features/alterTable';
@@ -49,7 +48,7 @@ describe('Db2Formatter', () => {
   supportsIdentifiers(format, [`""-qq`]);
   supportsBetween(format);
   supportsSchema(format);
-  supportsOperators(format, Db2Formatter.operators);
+  supportsOperators(format, ['**', '¬=', '¬>', '¬<', '!>', '!<', '||']);
   supportsJoin(format, { without: ['NATURAL'], supportsUsing: false });
   supportsSetOperations(format, [
     'UNION',
diff --git a/test/features/operators.ts b/test/features/operators.ts
index 22edd3a4..01979dab 100644
--- a/test/features/operators.ts
+++ b/test/features/operators.ts
@@ -7,9 +7,16 @@ export default function supportsOperators(
   operators: string[],
   logicalOperators: string[] = ['AND', 'OR']
 ) {
+  // Always test for standard SQL operators
+  const standardOperators = ['+', '-', '*', '/', '>', '<', '=', '<>', '<=', '>=', '!='];
+  operators = [...standardOperators, ...operators];
+
   operators.forEach(op => {
     it(`supports ${op} operator`, () => {
-      expect(format(`foo${op}bar`)).toBe(`foo ${op} bar`);
+      // Would be simpler to test with "foo${op}bar"
+      // but this doesn't work with "-" operator in bigQuery,
+      // where foo-bar is detected as identifier
+      expect(format(`foo${op} bar ${op}zap`)).toBe(`foo ${op} bar ${op} zap`);
     });
   });
 
diff --git a/test/hive.test.ts b/test/hive.test.ts
index caf99f47..b254045f 100644
--- a/test/hive.test.ts
+++ b/test/hive.test.ts
@@ -2,7 +2,6 @@ import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
 
-import HiveFormatter from 'src/languages/hive/hive.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsCreateTable from './features/createTable';
@@ -49,7 +48,7 @@ describe('HiveFormatter', () => {
     supportsUsing: false,
   });
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'UNION DISTINCT']);
-  supportsOperators(format, HiveFormatter.operators);
+  supportsOperators(format, ['%', '~', '^', '|', '&', '<=>', '==', '!', '||']);
   supportsArrayAndMapAccessors(format);
   supportsWindow(format);
   supportsLimiting(format, { limit: true });
diff --git a/test/mariadb.test.ts b/test/mariadb.test.ts
index b94d8448..2d072456 100644
--- a/test/mariadb.test.ts
+++ b/test/mariadb.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import MariaDbFormatter from 'src/languages/mariadb/mariadb.formatter';
 import behavesLikeMariaDbFormatter from './behavesLikeMariaDbFormatter';
 
 import supportsJoin from './features/join';
@@ -29,7 +28,11 @@ describe('MariaDbFormatter', () => {
     additionally: ['STRAIGHT_JOIN'],
   });
   supportsSetOperations(format, [...standardSetOperations, 'MINUS', 'MINUS ALL', 'MINUS DISTINCT']);
-  supportsOperators(format, MariaDbFormatter.operators, ['AND', 'OR', 'XOR']);
+  supportsOperators(
+    format,
+    ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||', '!'],
+    ['AND', 'OR', 'XOR']
+  );
   supportsReturning(format);
   supportsLimiting(format, { limit: true, offset: true, fetchFirst: true, fetchNext: true });
   supportsCreateTable(format, { orReplace: true, ifNotExists: true });
diff --git a/test/mysql.test.ts b/test/mysql.test.ts
index 3d144ae9..0ec62c87 100644
--- a/test/mysql.test.ts
+++ b/test/mysql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import MySqlFormatter from 'src/languages/mysql/mysql.formatter';
 import behavesLikeMariaDbFormatter from './behavesLikeMariaDbFormatter';
 
 import supportsJoin from './features/join';
@@ -29,7 +28,11 @@ describe('MySqlFormatter', () => {
     additionally: ['STRAIGHT_JOIN'],
   });
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'UNION DISTINCT']);
-  supportsOperators(format, MySqlFormatter.operators, ['AND', 'OR', 'XOR']);
+  supportsOperators(
+    format,
+    ['%', ':=', '&', '|', '^', '~', '<<', '>>', '<=>', '->', '->>', '&&', '||', '!'],
+    ['AND', 'OR', 'XOR']
+  );
   supportsWindow(format);
   supportsLimiting(format, { limit: true, offset: true });
   supportsCreateTable(format, { ifNotExists: true });
diff --git a/test/n1ql.test.ts b/test/n1ql.test.ts
index 5365b06a..f2e15104 100644
--- a/test/n1ql.test.ts
+++ b/test/n1ql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import N1qlFormatter from 'src/languages/n1ql/n1ql.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsBetween from './features/between';
@@ -34,7 +33,7 @@ describe('N1qlFormatter', () => {
   supportsIdentifiers(format, ['``']);
   supportsBetween(format);
   supportsSchema(format);
-  supportsOperators(format, N1qlFormatter.operators, ['AND', 'OR', 'XOR']);
+  supportsOperators(format, ['%', '==', '||'], ['AND', 'OR', 'XOR']);
   supportsArrayAndMapAccessors(format);
   supportsArrayLiterals(format);
   supportsJoin(format, { without: ['FULL', 'CROSS', 'NATURAL'], supportsUsing: false });
diff --git a/test/plsql.test.ts b/test/plsql.test.ts
index bb943ed6..1e2e0a9d 100644
--- a/test/plsql.test.ts
+++ b/test/plsql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import PlSqlFormatter from 'src/languages/plsql/plsql.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsAlterTable from './features/alterTable';
@@ -51,7 +50,12 @@ describe('PlSqlFormatter', () => {
   supportsIdentifiers(format, [`""-qq`]);
   supportsBetween(format);
   supportsSchema(format);
-  supportsOperators(format, PlSqlFormatter.operators, ['AND', 'OR', 'XOR']);
+  supportsOperators(
+    format,
+    // Missing: '..' operator
+    ['**', ':=', '%', '~=', '^=', '>>', '<<', '=>', '||'],
+    ['AND', 'OR', 'XOR']
+  );
   supportsJoin(format, { supportsApply: true });
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT']);
   supportsReturning(format);
@@ -74,15 +78,9 @@ describe('PlSqlFormatter', () => {
 
   // Parameters don't allow the same characters as identifiers
   it('does not support #, $ in named parameters', () => {
-    expect(format('SELECT :col$foo')).toBe(dedent`
-      SELECT
-        :col $ foo
-    `);
+    expect(() => format('SELECT :col$foo')).toThrowError(`Parse error: Unexpected "$foo"`);
 
-    expect(format('SELECT :col#foo')).toBe(dedent`
-      SELECT
-        :col # foo
-    `);
+    expect(() => format('SELECT :col#foo')).toThrowError(`Parse error: Unexpected "#foo"`);
   });
 
   it('supports &name substitution variables', () => {
@@ -104,7 +102,7 @@ describe('PlSqlFormatter', () => {
     expect(format("nQ'{test string { } 'foo' bar }'")).toBe("nQ'{test string { } 'foo' bar }'");
     expect(format("Nq'%test string % % 'foo' bar %'")).toBe("Nq'%test string % % 'foo' bar %'");
     expect(format("Q'Xtest string X X 'foo' bar X'")).toBe("Q'Xtest string X X 'foo' bar X'");
-    expect(format("q'$test string $'$''")).toBe("q'$test string $' $ ''");
+    expect(() => format("q'$test string $'$''")).toThrowError(`Parse error: Unexpected "$''"`);
     expect(format("Q'Stest string S'S''")).toBe("Q'Stest string S' S ''");
   });
 
diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts
index e87d6ca8..7839ee62 100644
--- a/test/postgresql.test.ts
+++ b/test/postgresql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import PostgreSqlFormatter from 'src/languages/postgresql/postgresql.formatter';
 
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 import supportsAlterTable from './features/alterTable';
@@ -52,10 +51,91 @@ describe('PostgreSqlFormatter', () => {
   supportsIdentifiers(format, [`""-qq`, 'U&""']);
   supportsBetween(format);
   supportsSchema(format);
-  supportsOperators(
-    format,
-    PostgreSqlFormatter.operators.filter(op => op !== '::')
-  );
+  // Missing: '::' type cast (tested separately)
+  supportsOperators(format, [
+    // Arithmetic
+    '%',
+    '^',
+    '|/',
+    '||/',
+    '@',
+    // Assignment
+    ':=',
+    // Bitwise
+    '&',
+    '|',
+    '#',
+    '~',
+    '<<',
+    '>>',
+    // Byte comparison
+    '~>~',
+    '~<~',
+    '~>=~',
+    '~<=~',
+    // Geometric
+    '@-@',
+    '@@',
+    '##',
+    '<->',
+    '&&',
+    '&<',
+    '&>',
+    '<<|',
+    '&<|',
+    '|>>',
+    '|&>',
+    '<^',
+    '^>',
+    '?#',
+    '?-',
+    '?|',
+    '?-|',
+    '?||',
+    '@>',
+    '<@',
+    '~=',
+    // JSON
+    '?',
+    '@?',
+    '?&',
+    '->',
+    '->>',
+    '#>',
+    '#>>',
+    '#-',
+    // Named function params
+    '=>',
+    // Network address
+    '>>=',
+    '<<=',
+    // Pattern matching
+    '~~',
+    '~~*',
+    '!~~',
+    '!~~*',
+    // POSIX RegExp
+    '~',
+    '~*',
+    '!~',
+    '!~*',
+    // Range/multirange
+    '-|-',
+    // String concatenation
+    '||',
+    // Text search
+    '@@@',
+    '!!',
+    // Trigram/trigraph
+    '<%',
+    '<<%',
+    '%>',
+    '%>>',
+    '<<->',
+    '<->>',
+    '<<<->',
+    '<->>>',
+  ]);
   supportsJoin(format);
   supportsSetOperations(format);
   supportsReturning(format);
@@ -105,6 +185,13 @@ describe('PostgreSqlFormatter', () => {
     `);
   });
 
+  it('formats type-cast operator without spaces', () => {
+    expect(format('SELECT 2 :: numeric AS foo;')).toBe(dedent`
+      SELECT
+        2::numeric AS foo;
+    `);
+  });
+
   // issue #144 (unsolved)
   // This is currently far from ideal.
   it('formats SELECT DISTINCT ON () syntax', () => {
diff --git a/test/redshift.test.ts b/test/redshift.test.ts
index 0c114223..2337114d 100644
--- a/test/redshift.test.ts
+++ b/test/redshift.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import RedshiftFormatter from 'src/languages/redshift/redshift.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsAlterTable from './features/alterTable';
@@ -42,7 +41,8 @@ describe('RedshiftFormatter', () => {
   supportsTruncateTable(format, { withoutTable: true });
   supportsStrings(format, ["''-qq"]);
   supportsIdentifiers(format, [`""-qq`]);
-  supportsOperators(format, RedshiftFormatter.operators);
+  // Missing: '#' operator
+  supportsOperators(format, ['^', '%', '@', '|/', '||/', '&', '|', '~', '<<', '>>', '||']);
   supportsJoin(format);
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT', 'MINUS']);
   supportsParams(format, { numbered: ['$'] });
diff --git a/test/singlestoredb.test.ts b/test/singlestoredb.test.ts
index c639985b..8aa6c184 100644
--- a/test/singlestoredb.test.ts
+++ b/test/singlestoredb.test.ts
@@ -1,5 +1,4 @@
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import SingleStoreDbFormatter from 'src/languages/singlestoredb/singlestoredb.formatter';
 import behavesLikeMariaDbFormatter from './behavesLikeMariaDbFormatter';
 
 import supportsJoin from './features/join';
@@ -32,7 +31,11 @@ describe('SingleStoreDbFormatter', () => {
     'INTERSECT',
     'MINUS',
   ]);
-  supportsOperators(format, SingleStoreDbFormatter.operators, ['AND', 'OR']);
+  supportsOperators(
+    format,
+    [':=', '&', '|', '^', '~', '<<', '>>', '<=>', '&&', '||'],
+    ['AND', 'OR']
+  );
   supportsLimiting(format, { limit: true, offset: true });
   supportsCreateTable(format, { ifNotExists: true });
   supportsCreateView(format);
diff --git a/test/spark.test.ts b/test/spark.test.ts
index 3ab78876..28cd72e3 100644
--- a/test/spark.test.ts
+++ b/test/spark.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import SparkFormatter from 'src/languages/spark/spark.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsAlterTable from './features/alterTable';
@@ -39,7 +38,11 @@ describe('SparkFormatter', () => {
   supportsStrings(format, ["''-bs", '""-bs', "X''", 'X""', "R''", 'R""']);
   supportsIdentifiers(format, ['``']);
   supportsBetween(format);
-  supportsOperators(format, SparkFormatter.operators, ['AND', 'OR', 'XOR']);
+  supportsOperators(
+    format,
+    ['%', '~', '^', '|', '&', '<=>', '==', '!', '||', '->'],
+    ['AND', 'OR', 'XOR']
+  );
   supportsArrayAndMapAccessors(format);
   supportsJoin(format, {
     additionally: [
diff --git a/test/sql.test.ts b/test/sql.test.ts
index e7ca60c4..a684396c 100644
--- a/test/sql.test.ts
+++ b/test/sql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import SqlFormatter from 'src/languages/sql/sql.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsCreateTable from './features/createTable';
@@ -51,24 +50,15 @@ describe('SqlFormatter', () => {
   supportsSchema(format);
   supportsJoin(format);
   supportsSetOperations(format);
-  supportsOperators(format, SqlFormatter.operators);
+  supportsOperators(format, []);
   supportsParams(format, { positional: true });
   supportsWindow(format);
   supportsLimiting(format, { limit: true, offset: true, fetchFirst: true, fetchNext: true });
 
-  // This is a crappy behavior, but at least we don't crash
-  it('does not crash when encountering characters or operators it does not recognize', () => {
-    expect(
-      format(`
-        SELECT @name, :bar FROM foo;
-      `)
-    ).toBe(dedent`
-      SELECT
-        @ name,
-      : bar
-      FROM
-        foo;
-    `);
+  it('throws error when encountering characters or operators it does not recognize', () => {
+    expect(() => format('SELECT @name, :bar FROM foo;')).toThrowError(
+      `Parse error: Unexpected "@name, :bar FROM foo;"`
+    );
   });
 
   it('crashes when encountering unsupported curly braces', () => {
diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts
index 1c52c46e..0879b1a2 100644
--- a/test/sqlite.test.ts
+++ b/test/sqlite.test.ts
@@ -1,6 +1,5 @@
 import dedent from 'dedent-js';
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import SqliteFormatter from 'src/languages/sqlite/sqlite.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsCreateTable from './features/createTable';
@@ -48,7 +47,7 @@ describe('SqliteFormatter', () => {
   supportsSchema(format);
   supportsJoin(format);
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT']);
-  supportsOperators(format, SqliteFormatter.operators);
+  supportsOperators(format, ['%', '~', '&', '|', '<<', '>>', '==', '->', '->>', '||']);
   supportsParams(format, { positional: true, numbered: ['?'], named: [':', '$', '@'] });
   supportsWindow(format);
   supportsLimiting(format, { limit: true, offset: true });
diff --git a/test/trino.test.ts b/test/trino.test.ts
index 1665e438..540b5219 100644
--- a/test/trino.test.ts
+++ b/test/trino.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import TrinoFormatter from 'src/languages/trino/trino.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsArrayLiterals from './features/arrayLiterals';
@@ -47,7 +46,8 @@ describe('TrinoFormatter', () => {
   supportsStrings(format, ["''-qq", "X''", "U&''"]);
   supportsIdentifiers(format, [`""-qq`]);
   supportsBetween(format);
-  supportsOperators(format, TrinoFormatter.operators, ['AND', 'OR']);
+  // Missing: '?' operator (for row patterns)
+  supportsOperators(format, ['%', '->', '||', '|', '^', '$'], ['AND', 'OR']);
   supportsArrayLiterals(format);
   supportsArrayAndMapAccessors(format);
   supportsJoin(format);
diff --git a/test/tsql.test.ts b/test/tsql.test.ts
index ae93c2aa..6d186d35 100644
--- a/test/tsql.test.ts
+++ b/test/tsql.test.ts
@@ -1,7 +1,6 @@
 import dedent from 'dedent-js';
 
 import { format as originalFormat, FormatFn } from 'src/sqlFormatter';
-import TSqlFormatter from 'src/languages/tsql/tsql.formatter';
 import behavesLikeSqlFormatter from './behavesLikeSqlFormatter';
 
 import supportsCreateTable from './features/createTable';
@@ -46,10 +45,24 @@ describe('TSqlFormatter', () => {
   supportsStrings(format, ["N''", "''-qq"]);
   supportsIdentifiers(format, [`""-qq`, '[]']);
   supportsBetween(format);
-  supportsOperators(
-    format,
-    TSqlFormatter.operators.filter(op => op !== '::')
-  );
+  // Missing: `::` scope resolution operator (tested separately)
+  supportsOperators(format, [
+    '%',
+    '&',
+    '|',
+    '^',
+    '~',
+    '!<',
+    '!>',
+    '+=',
+    '-=',
+    '*=',
+    '/=',
+    '%=',
+    '|=',
+    '&=',
+    '^=',
+  ]);
   supportsJoin(format, { without: ['NATURAL'], supportsUsing: false, supportsApply: true });
   supportsSetOperations(format, ['UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT']);
   supportsParams(format, { named: ['@'], quoted: ['@""', '@[]'] });
@@ -95,6 +108,13 @@ describe('TSqlFormatter', () => {
     `);
   });
 
+  it('formats scope resolution operator without spaces', () => {
+    expect(format('SELECT hierarchyid :: GetRoot();')).toBe(dedent`
+      SELECT
+        hierarchyid::GetRoot ();
+    `);
+  });
+
   it('formats ALTER TABLE ... ALTER COLUMN', () => {
     expect(format(`ALTER TABLE t ALTER COLUMN foo INT NOT NULL DEFAULT 5;`)).toBe(dedent`
       ALTER TABLE
diff --git a/test/unit/Parser.test.ts b/test/unit/Parser.test.ts
index 17e42f10..5da6416a 100644
--- a/test/unit/Parser.test.ts
+++ b/test/unit/Parser.test.ts
@@ -11,6 +11,7 @@ describe('Parser', () => {
       reservedJoins: ['JOIN'],
       reservedFunctionNames: ['SQRT', 'CURRENT_TIME'],
       reservedKeywords: ['BETWEEN', 'LIKE', 'ON', 'USING'],
+      operators: [':'],
       extraParens: ['[]', '{}'],
       stringTypes: ["''-qq"],
       identTypes: ['""-qq'],