Advanced

Sometimes raw database columns aren't enough. You might need to combine fields (FirstName + LastName), calculate values (Price * Tax), or format dates before sending them to the frontend.

1. Computed Columns (SQL Expressions)

You can define "virtual" columns using SQL expressions. These are calculated on the fly by the database during the query.

// src/config/users.ts
import { defineTable } from '@tablecraft/engine';
import { users } from '../db/schema';
import { sql } from 'drizzle-orm';

export const userConfig = defineTable(users)
  // Create a 'fullName' column from first and last names
  .computed('fullName', sql`CONCAT(${users.firstName}, ' ', ${users.lastName})`)

  // Calculate age from birthdate
  .computed('age', sql`EXTRACT(YEAR FROM AGE(${users.birthDate}))`)

  // Use these computed columns just like real ones!
  .sortable('fullName', 'age')
  .filter('age')
  .toConfig();
circle-check

2. Custom Search Logic

By default, .search('name') does a simple ILIKE %query%. You can customize this behavior by adding computed columns specifically for searching.

Create a computed column that concatenates searchable text, then use it for search.

3. Data Transformation

You often need to format data before sending it to the client.

Use SQL functions to transform the data before it leaves the database.

4. Static Filters (Base Conditions)

Sometimes you want to enforce a filter that the API user cannot change.

5. Type-Safe Date Filters

Relative Time (ago)

Filter for records within a relative time range (e.g., "last 30 days").

Date Truncation (dateTrunc)

Group data by time periods (day, month, year).

6. Subquery Columns

Subquery columns let you attach a correlated subquery to every row — useful for counts, existence checks, or fetching a single related record without a full join.

The filter parameter accepts three forms — pick whichever fits your style:

Import eq, and, or, gt, like etc. from drizzle-orm and pass them directly. These all return a SQL object — TableCraft passes it through unchanged. You own the safety of the expression. No restrictions.

You can also use sql\...`` tagged templates if you need raw SQL fragments:

Form 2 — Structured SubqueryCondition[] (typed, injection-safe)

Pass an array of condition objects. Each has left, op (default 'eq'), and right. Operands are { column: 'table.column' } or { value: literal }. Conditions are AND-combined; literal values are parameterized automatically.

Available operators: eq neq gt gte lt lte like ilike

Form 3 — Raw SQL string (@deprecated)

Still accepted for backwards compatibility. Must be a hardcoded developer-authored string — never user input. Prefer form 1 or 2 for new code.

Subquery types

Type
Return value
Sortable
Dialect

count

integer

✅ Yes

All

exists

boolean

✅ Yes

All

first

JSON object ({})

❌ No

PostgreSQL only

Sorting rules

count and exists return scalar values and can be used in ?sort=itemCount.

first uses row_to_json() which returns a JSON object — not a scalar. Attempting to sort by it will return a 400 FieldError before the query reaches the database:

Dialect requirement for first

Because first relies on row_to_json(), it is PostgreSQL-only. Using it with MySQL or SQLite throws a DialectError (HTTP 400):

count and exists work on all dialects.

Sortable flag on .computed()

The .computed() builder also accepts a sortable option for cases where a SQL expression is non-scalar:

Next Steps

Learn how to handle Security & Access Control to protect sensitive data.

Last updated

Was this helpful?