bencevans/node-influx

View on GitHub
src/builder.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { escape, formatDate } from "./grammar";

export interface IStringable {
  toString: () => string;
}

export interface IBaseExpression<T> {
  /**
   * Inserts a tag name in the expression.
   */
  tag: (name: string) => T;

  /**
   * Inserts a field name in the expression.
   */
  field: (name: string) => T;

  /**
   * Chains on a value to the expression. An error will be thrown if the
   * value is a type we can't represent in InfluxQL, primarily `null` or
   * `undefined.`
   */
  value: (value: any) => T;
}

export interface IExpressionHead extends IBaseExpression<IBinaryOp> {}

export interface IExpressionTail extends IBaseExpression<IExpressionHead> {}

export interface IBinaryOp {
  /**
   * Adds an 'AND' operator
   */
  and: IExpressionTail;

  /**
   * Adds an 'OR' operator
   */
  or: IExpressionTail;

  /**
   * Adds a '+' addition symbol
   */
  plus: IExpressionTail;

  /**
   * Adds a '*' multiplication symbol
   */
  times: IExpressionTail;

  /**
   * Adds a '-' subtraction symbol
   */
  minus: IExpressionTail;

  /**
   * Adds a '/' division symbol
   */
  div: IExpressionTail;

  /**
   * Adds a '=' symbol
   */
  equals: IExpressionTail;

  /**
   * Adds a '=~' comparator to select entries matching a regex.
   */
  matches: IExpressionTail;

  /**
   * Adds a '!~' comparator to select entries not matching a regex.
   */
  doesntMatch: IExpressionTail;

  /**
   * Adds a '!=' comparator to select entries not equaling a certain value.
   */
  notEqual: IExpressionTail;

  /**
   * Adds a '>' symbol
   */
  gt: IExpressionTail;

  /**
   * Adds a '>=' symbol
   */
  gte: IExpressionTail;

  /**
   * Adds a '<' symbol
   */
  lt: IExpressionTail;

  /**
   * Adds a '<=' symbol
   */
  lte: IExpressionTail;
}

function regexHasFlags(re: RegExp): boolean {
  if (typeof re.flags !== "undefined") {
    return re.flags.length > 0;
  }

  return !re.toString().endsWith("/");
}

/**
 * Expression is used to build filtering expressions, like those used in WHERE
 * clauses. It can be used for fluent and safe building of queries using
 * untrusted input.
 *
 * @example
 * e => e
 *   .field('host').equals.value('ares.peet.io')
 *   .or
 *   .field('host').matches(/example\.com$/)
 *   .or
 *   .expr(e => e
 *     .field('country').equals.value('US')
 *     .and
 *     .field('state').equals.value('WA'));
 *
 * // Generates:
 * // "host" = 'ares.peet.io' OR "host" ~= /example\.com$/ OR \
 * //   ("county" = 'US' AND "state" = 'WA')
 */
export class Expression implements IExpressionHead, IExpressionTail, IBinaryOp {
  private readonly _query: string[] = [];

  /**
   * Inserts a tag reference into the expression; the name will be
   * automatically escaped.
   * @param name
   * @return
   */
  public tag(name: string): this {
    this.field(name);
    return this;
  }

  /**
   * Inserts a field reference into the expression; the name will be
   * automatically escaped.
   * @param name
   * @return
   */
  public field(name: string): this {
    this._query.push(escape.quoted(name));
    return this;
  }

  /**
   * Inserts a subexpression; invokes the function with a new expression
   * that can be chained on.
   * @param fn
   * @return
   * @example
   * e.field('a').equals.value('b')
   *   .or.expr(e =>
   *     e.field('b').equals.value('b')
   *     .and.field('a').equals.value('c'))
   *   .toString()
   * // "a" = 'b' OR ("b" = 'b' AND "a" = 'c')
   */
  public exp(fn: (e: Expression) => Expression): this {
    this._query.push("(" + fn(new Expression()).toString() + ")");
    return this;
  }

  /**
   * Value chains on a value to the expression.
   *
   *  - Numbers will be inserted verbatim
   *  - Strings will be escaped and inserted
   *  - Booleans will be inserted correctly
   *  - Dates will be formatted and inserted correctly, including INanoDates.
   *  - Regular expressions will be inserted correctly, however an error will
   *    be thrown if they contain flags, as regex flags do not work in Influx
   *  - Otherwise we'll try to call `.toString()` on the value, throwing
   *    if we cannot do so.
   *
   * @param value
   * @return
   */
  public value(value: any): this {
    switch (typeof value) {
      case "number":
        this._query.push(value.toString());
        return this;
      case "string":
        this._query.push(escape.stringLit(value));
        return this;
      case "boolean":
        this._query.push(value ? "TRUE" : "FALSE");
        return this;
      default:
        if (value instanceof Date) {
          this._query.push(formatDate(value));
          return this;
        }

        if (value instanceof RegExp) {
          if (regexHasFlags(value)) {
            throw new Error(
              "Attempted to query using a regex with flags, " +
                "but Influx doesn't support flags in queries."
            );
          }

          this._query.push("/" + value.source + "/");
          return this;
        }

        if (value && typeof value.toString === "function") {
          this._query.push(value.toString());
          return this;
        }

        throw new Error(
          "node-influx doesn't know how to encode the provided value into a " +
            "query. If you think this is a bug, open an issue here: https://git.io/influx-err"
        );
    }
  }

  /**
   * Chains on an AND clause to the expression.
   */
  get and(): this {
    this._query.push("AND");
    return this;
  }

  /**
   * Chains on an OR clause to the expression.
   */
  get or(): this {
    this._query.push("OR");
    return this;
  }

  /**
   * Chains on a `+` operator to the expression.
   */
  get plus(): this {
    this._query.push("+");
    return this;
  }

  /**
   * Chains on a `*` operator to the expression.
   */
  get times(): this {
    this._query.push("*");
    return this;
  }

  /**
   * Chains on a `-` operator to the expression.
   */
  get minus(): this {
    this._query.push("-");
    return this;
  }

  /**
   * Chains on a `/` operator to the expression.
   */
  get div(): this {
    this._query.push("/");
    return this;
  }

  /**
   * Chains on a `=` conditional to the expression.
   */
  get equals(): this {
    this._query.push("=");
    return this;
  }

  /**
   * Chains on a `=~` conditional to the expression to match regexes.
   */
  get matches(): this {
    this._query.push("=~");
    return this;
  }

  /**
   * Chains on a `!`` conditional to the expression to match regexes.
   */
  get doesntMatch(): this {
    this._query.push("!~");
    return this;
  }

  /**
   * Chains on a `!=` conditional to the expression.
   */
  get notEqual(): this {
    this._query.push("!=");
    return this;
  }

  /**
   * Chains on a `>` conditional to the expression.
   */
  get gt(): this {
    this._query.push(">");
    return this;
  }

  /**
   * Chains on a `>=` conditional to the expression.
   */
  get gte(): this {
    this._query.push(">=");
    return this;
  }

  /**
   * Chains on a `<` conditional to the expression.
   */
  get lt(): this {
    this._query.push("<");
    return this;
  }

  /**
   * Chains on a `<=` conditional to the expression.
   */
  get lte(): this {
    this._query.push("<=");
    return this;
  }

  /**
   * Converts the expression into its InfluxQL representation.
   * @return
   */
  public toString(): string {
    return this._query.join(" ");
  }
}

/**
 * Measurement creates a reference to a particular measurement. You can
 * reference it solely by its name, but you can also specify the retention
 * policy and database it lives under.
 *
 * @example
 * m.name('my_measurement') // "my_measurement"
 * m.name('my_measurement').policy('one_day') // "one_day"."my_measurement"
 * m.name('my_measurement').policy('one_day').db('mydb') // "mydb"."one_day"."my_measurement"
 */
export class Measurement {
  private _parts: string[] = [null, null, null];

  /**
   * Sets the measurement name.
   * @param name
   * @return
   */
  public name(name: string): this {
    this._parts[2] = name;
    return this;
  }

  /**
   * Sets the retention policy name.
   * @param retentionPolicy
   * @return
   */
  public policy(retentionPolicy: string): this {
    this._parts[1] = retentionPolicy;
    return this;
  }

  /**
   * Sets the database name.
   * @param db
   * @return
   */
  public db(db: string): this {
    this._parts[0] = db;
    return this;
  }

  /**
   * Converts the measurement into its InfluxQL representation.
   * @return
   * @throws {Error} if a measurement name is not provided
   */
  public toString(): string {
    if (!this._parts[2]) {
      throw new Error(
        `You must specify a measurement name to query! Got \`${this._parts[2]}\``
      );
    }

    return this._parts
      .filter((p) => Boolean(p))
      .map((p) => escape.quoted(p))
      .join(".");
  }
}

export type measurement = {
  measurement: string | ((m: Measurement) => IStringable);
};
export type where = { where: string | ((e: IExpressionHead) => IStringable) };

export function parseMeasurement(q: measurement): string {
  if (typeof q.measurement === "function") {
    return q.measurement(new Measurement()).toString();
  }

  return q.measurement;
}

export function parseWhere(q: where): string {
  if (typeof q.where === "function") {
    return q.where(new Expression()).toString();
  }

  return q.where;
}