Skip to content

Commit

Permalink
feat: allow customizing quoting behavior of emit (#34) (#35)
Browse files Browse the repository at this point in the history
Add an optional second parameter to emit to customize quotes output

Co-authored-by: Tim Hambourger <tim.hambourger@concretedata.com>
  • Loading branch information
TimHambourger and Tim Hambourger authored Jul 8, 2022
1 parent 5550730 commit 9e613d9
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 19 deletions.
15 changes: 13 additions & 2 deletions packages/emitter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,20 @@ yarn add @rsql/emitter

## API

#### `emit(expression: ExpressionNode): string`
#### `emit(expression: ExpressionNode, options?: EmitOptions): string`

Emits RSQL string from the Abstract Syntax Tree. It can throw the following errors:
Emits RSQL string from the Abstract Syntax Tree. The second parameter to `emit` is an optional object with the following
fields:

- `preferredQuote` - Optional string. The preferred quote character to use when `emit` encounters a comparison value
that needs to be escaped by wrapping in quotes. Either `"` (the ASCII double quote character) or `'` (the ASCII single
quote character). Defaults to `"` (the ASCII double quote character).

- `optimizeQuotes` - Optional boolean. If `true`, `emit` will override the `preferredQuote` setting on a comparison
value-by-comparison value basis if doing so would shorten the emitted RSQL. If `false`, `emit` will use the
`preferredQuote` as the quote character for all comparison values encountered. Defaults to `true`.

`emit` can throw the following errors:

- `TypeError` - in the case of invalid argument type passed to the `emit` function

Expand Down
83 changes: 67 additions & 16 deletions packages/emitter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ import {

type Quote = '"' | "'";

interface EmitOptions {
/**
* The preferred quote character to use when `emit` encounters a comparison value that needs to be escaped by wrapping
* in quotes. Either `"` (the ASCII double quote character) or `'` (the ASCII single quote character). Defaults to `"`
* (the ASCII double quote character).
*/
preferredQuote?: Quote;
/**
* If `true`, `emit` will override the `preferredQuote` setting on a comparison value-by-comparison value basis if
* doing so would shorten the emitted RSQL. If `false`, `emit` will use the `preferredQuote` as the quote character
* for all comparison values encountered. Defaults to `true`.
*/
optimizeQuotes?: boolean;
}

const DEFAULT_EMIT_OPTIONS: Required<EmitOptions> = {
preferredQuote: '"',
optimizeQuotes: true,
};

const NEEDS_ESCAPING: { [Q in Quote]: RegExp } = {
'"': /"|\\/g,
"'": /'|\\/g,
Expand All @@ -25,12 +45,34 @@ function escapeQuotes(value: string, quote: Quote) {
return value.replace(NEEDS_ESCAPING[quote], "\\$&");
}

function escapeValue(value: string, quote: Quote) {
if (value === "") {
return quote + quote;
function countQuote(value: string, quote: Quote) {
let count = 0;
for (let i = 0; i < value.length; ++i) {
if (value[i] === quote) {
count++;
}
}
return count;
}

function selectQuote(
value: string,
{
preferredQuote = DEFAULT_EMIT_OPTIONS.preferredQuote,
optimizeQuotes = DEFAULT_EMIT_OPTIONS.optimizeQuotes,
}: EmitOptions
) {
if (optimizeQuotes) {
const otherQuote: Quote = preferredQuote === '"' ? "'" : '"';
return countQuote(value, otherQuote) < countQuote(value, preferredQuote) ? otherQuote : preferredQuote;
} else {
return preferredQuote;
}
}

if (ReservedChars.some((reservedChar) => value.includes(reservedChar))) {
function escapeValue(value: string, options: EmitOptions) {
if (value === "" || ReservedChars.some((reservedChar) => value.includes(reservedChar))) {
const quote = selectQuote(value, options);
return `${quote}${escapeQuotes(value, quote)}${quote}`;
}

Expand All @@ -41,19 +83,19 @@ function emitSelector(node: SelectorNode) {
return node.selector;
}

function emitValue(node: ValueNode, quote: Quote = '"') {
function emitValue(node: ValueNode, options: EmitOptions) {
return Array.isArray(node.value)
? `(${node.value.map((value) => escapeValue(value, quote)).join(",")})`
: escapeValue(node.value, quote);
? `(${node.value.map((value) => escapeValue(value, options)).join(",")})`
: escapeValue(node.value, options);
}

function emitComparison(node: ComparisonNode) {
return `${emitSelector(node.left)}${node.operator}${emitValue(node.right)}`;
function emitComparison(node: ComparisonNode, options: EmitOptions) {
return `${emitSelector(node.left)}${node.operator}${emitValue(node.right, options)}`;
}

function emitLogic(node: LogicNode) {
let left = emit(node.left);
let right = emit(node.right);
function emitLogic(node: LogicNode, options: EmitOptions) {
let left = emitWithoutOptionsValidation(node.left, options);
let right = emitWithoutOptionsValidation(node.right, options);

// handle operator precedence - as it's only the case for AND operator, we don't need a generic logic for that
if (isLogicOperator(node.operator, AND)) {
Expand All @@ -71,14 +113,23 @@ function emitLogic(node: LogicNode) {
return `${left}${operator}${right}`;
}

function emit(expression: ExpressionNode): string {
function emitWithoutOptionsValidation(expression: ExpressionNode, options: EmitOptions): string {
if (isComparisonNode(expression)) {
return emitComparison(expression);
return emitComparison(expression, options);
} else if (isLogicNode(expression)) {
return emitLogic(expression);
return emitLogic(expression, options);
}

throw new TypeError(`The "expression" has to be a valid "ExpressionNode", ${String(expression)} passed.`);
}

export { emit };
function emit(expression: ExpressionNode, options: EmitOptions = {}) {
if (options.preferredQuote !== undefined && options.preferredQuote !== '"' && options.preferredQuote !== "'") {
throw new TypeError(
`Invalid "preferredQuote" option: ${options.preferredQuote}. Must be either " (the ASCII double quote character) or ' (the ASCII single quote character).`
);
}
return emitWithoutOptionsValidation(expression, options);
}

export { emit, EmitOptions, Quote };
22 changes: 21 additions & 1 deletion tests/emitter/emit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("emit", () => {

it.each([
["hi there!", '"hi there!"'],
['hi "there\\!', '"hi \\"there\\\\!"'],
['hi "there\\!', "'hi \"there\\\\!'"],
["Pěkný den!", '"Pěkný den!"'],
["Flynn's *", '"Flynn\'s *"'],
["o)'O'(o", "\"o)'O'(o\""],
Expand All @@ -52,6 +52,26 @@ describe("emit", () => {
expect(parsedAst).toEqual(ast);
});

it.each([
['hi "there!', { preferredQuote: '"' as const, optimizeQuotes: true }, "'hi \"there!'"],
['hi "there!', { preferredQuote: "'" as const, optimizeQuotes: true }, "'hi \"there!'"],
['hi "there!', { preferredQuote: '"' as const, optimizeQuotes: false }, '"hi \\"there!"'],
['hi "there!', { preferredQuote: "'" as const, optimizeQuotes: false }, "'hi \"there!'"],
["hi 'there!", { preferredQuote: '"' as const, optimizeQuotes: true }, '"hi \'there!"'],
["hi 'there!", { preferredQuote: "'" as const, optimizeQuotes: true }, '"hi \'there!"'],
["hi 'there!", { preferredQuote: '"' as const, optimizeQuotes: false }, '"hi \'there!"'],
["hi 'there!", { preferredQuote: "'" as const, optimizeQuotes: false }, "'hi \\'there!'"],
])('honors provided emit options "%p"', (value, opts, escapedValue) => {
const ast = builder.comparison("selector", "==", value);
const emittedRsql = emit(ast, opts);
const expectedRsql = `selector==${escapedValue}`;

expect(emittedRsql).toEqual(expectedRsql);

const parsedAst = parse(emittedRsql);
expect(parsedAst).toEqual(ast);
});

test('Empty string will be emitted as ""', () => {
const rsql = `selector==""`;
const ast = parse(rsql);
Expand Down

0 comments on commit 9e613d9

Please sign in to comment.