zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Schema and ORM

Naming StrategySupported

The build-time boundary#

A table declaration has two vocabularies:

interface ColumnIR {
  name: string; // TypeScript property and DTO key
  physicalName: string; // SQL identifier
}

interface SchemaIR {
  table: string; // declared table identity
  physicalTable: string; // SQL table identifier
}

The naming strategy runs while the AOT reflector creates this IR. It runs once per table and once per real column, then disappears. There is no query-time naming hook and no per-query strategy call:

For authorId → author_id and blogPost → blog_posts, a repository query can therefore compile to:

SELECT "id", "author_id" AS "authorId"
FROM "blog_posts"
WHERE "author_id" = $1

The returned entity still has an authorId property.

Built-in strategies#

The public implementations live at @zmdb/schema/naming:

import { resolveNaming, snakeCase, snakeCasePlural } from '@zmdb/schema/naming';

snakeCase handles acronym and digit boundaries rather than inserting an underscore before every capital:

DeclaredPhysical
createdAtcreated_at
HTTPStatushttp_status
id2id2
userIDuser_id
already_snakealready_snake

snakeCasePlural uses the same column conversion and pluralises the final table word: userAccount → user_accounts, blogPost → blog_posts, and person → people. The pluraliser is deliberately a small deterministic rule set, not a linguistics package. Uninflected or ambiguous words such as metadata, series, and species stay unchanged; use a custom strategy for domain vocabulary outside the built-in rules.

Configure it once#

// zmdb.config.ts
import { postgres } from '@zmdb/core/postgres';
import { defineConfig } from '@zmdb/core/config';

export default defineConfig({
  schema: 'src/**/*.schema.ts',
  dialect: postgres,
  naming: 'snake_case_plural',
});

loadConfig() returns resolvedNaming, which is always a strategy object:

A custom object wins if both config fields are present:

export default defineConfig({
  schema: 'src/**/*.schema.ts',
  dialect: postgres,
  namingStrategy: {
    table: declared => `app_${declared.toLowerCase()}`,
    column: property => property.replaceAll(/[A-Z]/g, letter => `_${letter.toLowerCase()}`),
  },
});

Database commands, @zmdb/compiler project compilation, and the product compiler entry all pass resolvedNaming into reflection automatically:

import { zmdbAot } from '@zmdb/core/compiler';

const plugin = await zmdbAot();

The lower-level @zmdb/compiler APIs accept an explicit naming option for tools that own config loading. The build plugin and project compiler resolve the same project config, so both emit the same physical names.

Explicit overrides#

Import Physical from either documented tag subpath:

import type { Physical, PrimaryKey, Sql, Table } from '@zmdb/core/tags';

export interface User extends Table<'userAccount'>, Physical<'legacy_users'> {
  id: number & Sql<'integer'> & PrimaryKey;
  createdAt: Date & Sql<'timestamp'> & Physical<'created_ts'>;
}

The interface-level tag fixes the SQL table name at legacy_users; the property intersection fixes the SQL column at created_ts. Each explicit name is resolved before the configured strategy, so the corresponding strategy callback is not invoked. The declared table identity remains userAccount, and the returned entity property remains createdAt.

Sharp edges#

A collision is a build diagnostic, not a query-time surprise. For example, createdAt and created_at both become created_at under snake_case, so the diagnostic names both properties and the physical name they share.

Raw SQL is never rewritten. A partial-index predicate written as createdAt IS NOT NULL remains exactly that string; write the physical created_at identifier yourself.

Turning on or changing a strategy under an existing database changes snapshot names. A diff cannot safely guess whether createdAt → created_at is a rename or a drop plus add, so review and author the rename migration explicitly.

---

See also: Schema Declaration · Configuration · Migrations