filter-expression.js

/**
 * Supporting class for building DynamoDB filter expressions.
 * @class
 */
class FilterExpressionBuilder {
  /**
   * @constructor
   * @description
   * Do not instantiate this class directly. This is used by {@link BaoModel.queryByIndex}.
   */
  constructor() {
    this.names = {};
    this.values = {};
    this.nameCount = 0;
    this.valueCount = 0;
  }

  // Generate unique name placeholder
  generateName(fieldName) {
    const key = `#n${++this.nameCount}`;
    this.names[key] = fieldName;
    return key;
  }

  // Generate unique value placeholder
  generateValue(value) {
    const key = `:v${++this.valueCount}`;
    this.values[key] = value;
    return key;
  }

  // Simplified value conversion - let the field handle it
  convertValue(value, model, fieldName) {
    const field = model.fields[fieldName];
    return field.toDy(value);
  }

  // Build expression for a single comparison
  buildComparison(fieldName, operator, value, model) {
    const nameKey = this.generateName(fieldName);
    const convertedValue = this.convertValue(value, model, fieldName);

    switch (operator) {
      case "$eq":
        const valueKey = this.generateValue(convertedValue);
        return `${nameKey} = ${valueKey}`;
      case "$ne":
        return `${nameKey} <> ${this.generateValue(convertedValue)}`;
      case "$gt":
        return `${nameKey} > ${this.generateValue(convertedValue)}`;
      case "$gte":
        return `${nameKey} >= ${this.generateValue(convertedValue)}`;
      case "$lt":
        return `${nameKey} < ${this.generateValue(convertedValue)}`;
      case "$lte":
        return `${nameKey} <= ${this.generateValue(convertedValue)}`;
      case "$contains":
        return `contains(${nameKey}, ${this.generateValue(convertedValue)})`;
      case "$beginsWith":
        return `begins_with(${nameKey}, ${this.generateValue(convertedValue)})`;
      case "$in":
        if (!Array.isArray(convertedValue)) {
          throw new Error("$in operator requires an array value");
        }
        const valueKeys = convertedValue.map((v) => this.generateValue(v));
        return `${nameKey} IN (${valueKeys.join(", ")})`;
      default:
        throw new Error(`Unsupported operator: ${operator}`);
    }
  }

  // Build expression for a field
  buildFieldExpression(fieldName, condition, model) {
    if (condition === null) {
      const nameKey = this.generateName(fieldName);
      return `attribute_not_exists(${nameKey})`;
    }

    if (typeof condition !== "object" || condition instanceof Date) {
      return this.buildComparison(fieldName, "$eq", condition, model);
    }

    const operators = Object.keys(condition);
    if (operators.length === 0) {
      throw new Error(`Empty condition object for field: ${fieldName}`);
    }

    const expressions = operators.map((operator) => {
      return this.buildComparison(
        fieldName,
        operator,
        condition[operator],
        model,
      );
    });

    return expressions.join(" AND ");
  }

  // Build expression for logical operators
  buildLogicalExpression(operator, conditions, model) {
    if (!Array.isArray(conditions)) {
      throw new Error(`${operator} requires an array of conditions`);
    }

    const expressions = conditions.map((condition) => {
      return this.buildFilterExpression(condition, model);
    });

    const joinOperator = operator === "$and" ? " AND " : " OR ";
    return `(${expressions.join(joinOperator)})`;
  }

  // Main entry point for building filter expression
  buildFilterExpression(filter, model) {
    if (!filter || Object.keys(filter).length === 0) {
      return null;
    }

    const expressions = [];

    for (const [key, value] of Object.entries(filter)) {
      if (key === "$and" || key === "$or") {
        expressions.push(this.buildLogicalExpression(key, value, model));
      } else if (key === "$not") {
        const innerExpr = this.buildFilterExpression(value, model);
        expressions.push(`NOT ${innerExpr}`);
      } else {
        expressions.push(this.buildFieldExpression(key, value, model));
      }
    }

    return expressions.join(" AND ");
  }

  /**
   * Build a filter expression for a given filter and model.
   * @param {Object} filter - The filter object to build the expression for. The filter can contain:
   *   - Simple field comparisons: { fieldName: value } for exact matches
   *   - Comparison operators: { fieldName: { $eq: value, $ne: value, $gt: value, $gte: value, $lt: value, $lte: value } }
   *   - String operators: { fieldName: { $beginsWith: value, $contains: value } }
   *   - Logical operators:
   *     - $and: [{condition1}, {condition2}] - All conditions must match
   *     - $or: [{condition1}, {condition2}] - At least one condition must match
   *     - $not: {condition} - Condition must not match
   * @param {BaoModel} model - The model to build the expression for. Used to validate field names.
   * @example
   * filterExp1 = {
   *     status: 'active',  // Exact match
   * }
   *
   * filterExp2 = {
   *     age: { $gt: 21 },  // Greater than comparison
   * }
   *
   * filterExp3 = {
   *     roles: { $contains: 'admin' }, // String contains
   * }
   *
   * filterExp4 = {
   *     // OR condition
   *     $or: [
   *       { type: 'user' },
   *       { type: 'admin' }
   *     ]
   *   }
   * @returns {Object|null} Returns null if no filter provided, otherwise returns an object containing:
   *   - FilterExpression: String DynamoDB filter expression
   *   - ExpressionAttributeNames: Map of attribute name placeholders
   *   - ExpressionAttributeValues: Map of attribute value placeholders
   */
  build(filter, model) {
    // Validate field names against model
    this.validateFields(filter, model);

    const filterExpression = this.buildFilterExpression(filter, model);

    if (!filterExpression) {
      return null;
    }

    return {
      FilterExpression: filterExpression,
      ExpressionAttributeNames: this.names,
      ExpressionAttributeValues: this.values,
    };
  }

  // Validate fields against model definition
  validateFields(filter, model) {
    const validateObject = (obj) => {
      for (const [key, value] of Object.entries(obj)) {
        // Skip logical operators
        if (["$and", "$or", "$not"].includes(key)) {
          if (Array.isArray(value)) {
            value.forEach(validateObject);
          } else {
            validateObject(value);
          }
          continue;
        }

        // Validate field exists in model
        if (!model.fields[key]) {
          throw new Error(`Unknown field in filter: ${key}`);
        }

        // Recursively validate nested conditions
        if (
          value &&
          typeof value === "object" &&
          !Array.isArray(value) &&
          !(value instanceof Date)
        ) {
          const operators = Object.keys(value);
          operators.forEach((op) => {
            if (
              ![
                "$eq",
                "$ne",
                "$gt",
                "$gte",
                "$lt",
                "$lte",
                "$in",
                "$contains",
                "$beginsWith",
              ].includes(op)
            ) {
              throw new Error(`Invalid operator ${op} for field ${key}`);
            }
          });
        }
      }
    };

    validateObject(filter);
  }
}

module.exports = { FilterExpressionBuilder };