Skip to content

Result Translation for Plugin Authors

Who this is for

This is a plugin-author SPI guide. Application users receive typed entities and projections from collection terminal methods; they do not translate backend result sets themselves.

After a custom plugin executes a backend query, it must translate raw results into the shape expected by Routier. The DataTranslator class hierarchy provides that framework and applies operations the backend did not perform.

Overview

The translator's purpose is to adapt query results based on what the backend can or cannot do natively. For example:

  • If your backend supports COUNT natively (like SQL), the translator just extracts the count value from the result
  • If your backend doesn't support COUNT (like a simple in-memory store), the translator receives all data and counts it in memory

Routier provides two main translator implementations:

  • JsonTranslator: For backends that don't support query operations natively—returns all data, translator performs operations in JavaScript
  • SqlTranslator: For SQL backends that handle most operations natively—translator extracts/adjusts results from SQL queries

Both extend DataTranslator, which orchestrates the translation process based on what operations the backend already performed.

How Translation Works

The DataTranslator.translate() method processes query results by applying each query option in order:

ts
translate(data: unknown): TShape {
    this.query.options.forEach(item => {
        data = this.functionMap[item.name](data, item);
    });
    return data as TShape;
}

Translation happens in the order operations were added to the query, ensuring correct semantics.

JsonTranslator

Used for backends that don't support query operations natively. The plugin returns all matching data, and the translator performs filtering, sorting, aggregation, and pagination in JavaScript.

Operations Handled

Filtering

ts
filter<TResult>(data: unknown, option: QueryOption<TShape, "filter">): TResult {
    if (option.value.filter) {
        if (option.value.params == null) {
            // Standard filtering
            return data.filter(option.value.filter) as TResult;
        }
        // Parameterized filtering
        const selector = option.value.filter as ParamsFilter<unknown, {}>
        return data.filter(w => selector([w, option.value.params])) as TResult;
    }
    return data as TResult;
}

Sorting

ts
sort<TResult>(data: unknown, option: QueryOption<TShape, "sort">): TResult {
    if (Array.isArray(data)) {
        data.sort((a, b) => {
            const aVal = option.value.selector(a);
            const bVal = option.value.selector(b);
            return option.value.direction === "asc"
                ? aVal - bVal
                : bVal - aVal;
        });
    }
    return data as TResult;
}

Aggregations

  • count: Returns data.length
  • sum: Iterates and sums values (requires mapped field)
  • min/max: Sorts and returns first element

Aggregations

Since the backend doesn't support aggregations natively:

ts
count<TResult>(data: unknown, _: QueryOption<TShape, "count">): TResult {
    if (Array.isArray(data)) {
        return data.length as TResult; // Count all data in memory
    }
    throw new Error("Cannot count resulting data, it must be an array");
}

sum<TResult>(data: unknown, _: QueryOption<TShape, "sum">): TResult {
    assertIsArray(data);
    let sum = 0;
    for (const value of data) {
        if (typeof value !== "number") {
            throw new Error("Cannot sum, property is not a number");
        }
        sum += value; // Sum all data in memory
    }
    return sum as TResult;
}

min<TResult>(data: unknown, _: QueryOption<TShape, "min">): TResult {
    assertIsArray(data);
    data.sort((a, b) => a - b); // Sort all data in memory
    return data[0] as TResult; // Return first (minimum) element
}

Why: These backends don't have aggregate functions, so the translator receives all matching data and performs the calculation.

Pagination

  • skip: data.slice(option.value) - Slices array in memory
  • take: data.slice(0, option.value) - Takes first N items in memory

Why: Backend doesn't support LIMIT/OFFSET, so translator handles pagination.

Distinct

ts
distinct<TResult>(data: unknown, _: QueryOption<TShape, "distinct">): TResult {
    const result = new Set<string | number | Date>();

    for (const value of data) {
        if (typeof value === "number" || typeof value === "string") {
            result.add(value);
        } else if (isDate(value)) {
            result.add(value.toISOString());
        }
    }

    return [...result] as TResult;
}

When to Use JsonTranslator

Use JsonTranslator when your backend:

  • Doesn't support filtering → Returns all data, translator filters in memory
  • Doesn't support sorting → Returns unsorted data, translator sorts in memory
  • Doesn't support aggregations (COUNT, SUM, MIN, MAX) → Translator calculates from all data
  • Doesn't support pagination → Returns all data, translator applies skip/take

Common backends:

  • Memory-based stores (MemoryPlugin)
  • Simple key-value stores
  • Backends that only support basic retrieval

SqlTranslator

Used for SQL backends where the database natively supports query operations. The translator extracts and adjusts results since the database already performed the work.

Key Differences from JsonTranslator

  1. Count: SQL already executed COUNT(*), so extract the value
ts
   count<TResult>(data: unknown, _: QueryOption<TShape, "count">): TResult {
       if (Array.isArray(data) && data.length > 0) {
           return data[0].count; // SQL already calculated: { count: number }
       }
       return data as TResult;
   }
   ```

   **Why**: SQL natively supports COUNT, so the database already performed the operation. We just extract the result.

2. **Filter/Sort/Skip/Take**: No-op since SQL already handled these

   ```ts
   filter<TResult>(data: unknown, _: QueryOption<TShape, "filter">): TResult {
       return data as TResult; // SQL WHERE clause already filtered
   }
   ```

   **Why**: SQL's WHERE, ORDER BY, LIMIT, and OFFSET already performed these operations. The data is already in the correct shape.

3. **Min/Max/Sum**: Extract from SQL aggregate results

   ```ts
   min<TResult>(data: unknown, _: QueryOption<TShape, "min">): TResult {
       if (Array.isArray(data) && data.length > 0) {
           return data[0]; // SQL already found the min value
       }
       return data as TResult;
   }
   ```

   **Why**: SQL's `MIN()`, `MAX()`, `SUM()` functions already calculated the values. Just extract them.

4. **Map**: Still needed for field mapping and deserialization
   ```ts
   map(data: unknown, option: QueryOption<TShape, "map">): TShape {
       // Deserialize properties and apply selector
       for (const field of option.value.fields) {
           if (field.property != null) {
               const value = field.property.getValue(data[i]);
               if (value != null) {
                   field.property.setValue(data[i], field.property.deserialize(value));
               }
           }
       }
       response.push(option.value.selector(data[i]));
   }
   ```

### When to Use SqlTranslator

Use `SqlTranslator` when your backend:

- **Supports filtering natively** → Database performs WHERE clauses
- **Supports sorting natively** → Database performs ORDER BY
- **Supports aggregations natively** → Database performs COUNT, SUM, MIN, MAX, etc.
- **Supports pagination natively** → Database performs LIMIT/OFFSET

In these cases, the translator's job is to extract and adjust results, not perform the operations.

### Special Handling

- **Count with Map**: If count and map are both present, count takes precedence (SQL already calculated count, mapping not needed)
- **Min/Max/Sum**: Extract single value from SQL result array (database already calculated the aggregate)

## Map Operation

Both translators handle the `map` operation, which:

1. **Deserializes** property values (e.g., JSON strings → objects, date strings → Date objects)
2. **Applies** the selector function to transform the shape
3. **Handles** nested property access and computed properties

ts map(data: unknown, option: QueryOption<TShape, "map">): TShape { const response = [];

for (const entity of data) {
    // Deserialize each mapped field
    for (const field of option.value.fields) {
        if (field.property != null) {
            const value = field.property.getValue(entity);
            if (value != null) {
                // Deserialize (e.g., JSON.parse, Date conversion)
                field.property.setValue(entity, field.property.deserialize(value));
            }
        }
    }

    // Apply selector to reshape
    response.push(option.value.selector(entity));
}

return response as TShape;

}


## Decision: Which Translator to Use?

The choice between `JsonTranslator` and `SqlTranslator` (or creating your own) depends on **what your backend can do natively**:

| Operation       | Backend Doesn't Support                 | Backend Supports                               |
| --------------- | --------------------------------------- | ---------------------------------------------- |
| **Filter**      | Return all data → Translator filters    | Apply WHERE → Translator passes through        |
| **Count**       | Return all data → Translator counts     | Execute COUNT(\*) → Translator extracts        |
| **Sort**        | Return unsorted → Translator sorts      | Apply ORDER BY → Translator passes through     |
| **Skip/Take**   | Return all data → Translator slices     | Apply LIMIT/OFFSET → Translator passes through |
| **Min/Max/Sum** | Return all data → Translator calculates | Execute aggregate → Translator extracts        |

### Hybrid Approach

Your backend might support some operations but not others. You can create a custom translator that:

- Passes through operations your backend handles (like `SqlTranslator`)
- Performs operations your backend doesn't handle (like `JsonTranslator`)

## Creating Your Own Translator

If your backend needs custom translation logic, extend `DataTranslator` and implement methods based on what your backend supports:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-7.ts


### Usage in Plugin

The `DataTranslator.translate()` method automatically wraps results in `ITranslatedValue`, so you don't need to manually wrap them:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-8.ts


## Common Patterns

### Deserialization

The schema handles deserialization automatically through property-level `deserialize()` methods. You don't need to manually deserialize in the translator. The `map` operation already handles deserialization for mapped fields:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-9.ts


If your backend returns raw data that needs schema-level deserialization, handle it in your plugin's `query` method before passing to the translator, not in the translator itself.

### Hybrid: Backend Supports Some Operations

If your backend supports some operations but not others, check what was handled:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-10.ts


### Filtering After Fetch

If your backend doesn't support certain filters, fetch and filter in memory:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-11.ts


The key principle: **If your backend already did the work, extract the result. If not, do the work in the translator.**

### Result Shaping

Transform backend-specific result structures:


<<< @/_snippets/code/from-docs/integrations/plugins/advanced-plugins/result-translation/index/block-12.ts


## Reference Implementations

- **[JsonTranslator](https://github.com/agrejus/routier/blob/main/core/src/plugins/translators/JsonTranslator.ts)**: Full in-memory operations
- **[SqlTranslator](https://github.com/agrejus/routier/blob/main/core/src/plugins/translators/SqlTranslator.ts)**: SQL result handling
- **[PouchDbTranslator](https://github.com/agrejus/routier/blob/main/plugins/pouchdb/src/PouchDbTranslator.ts)**: Custom deserialization example

## Testing

See [JsonTranslator.test.ts](https://github.com/agrejus/routier/blob/main/core/src/plugins/translators/JsonTranslator.test.ts) for comprehensive test examples covering all translation operations.

Released under the MIT License.