MemberJunction / MemberJunction/MJ

PostgreSQL Schema Casing for Entity Class Names

Open
#2,995 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TSQL
Stars
29
Forks
6
Avg merge
2d 1h
Merged PRs (30d)
323

Description

PostgreSQL Schema Casing for Entity Class Names — Options & Decision

---

## 1. The problem (one paragraph)

MJ derives entity class names (and GraphQL type names) from the schema name. On PostgreSQL,
unquoted DDL identifiers are folded to lowercase (SQL standard behavior; SQL Server, non-standard,
preserves case). So a schema authored `__mj_BizAppsCommon` becomes physically `__mj_bizappscommon`
on PG, and CodeGen emits `mjbizappscommonAddressEntity` instead of the published
`mjBizAppsCommonAddressEntity` → consuming builds fail with **TS2724**. Affects every mixed-case-schema
deployment on PostgreSQL. SQL Server is unaffected.

### 1a. Why this is a CROSS-PACKAGE contract break (the actual failure mechanism)

The entity class name is not a local, cosmetic string — it is the **linking symbol across a package
boundary**, and it is computed **independently in two places that must agree**:

- **The PRODUCER package** — e.g. `@mj-biz-apps/common-entities` (published to npm at 5.31.2). Its
generated `entity_subclasses.ts` **exports** the class names, baked in at the time *it* was generated
(on SQL Server → PascalCase):
```ts
export class mjBizAppsCommonPersonEntity extends BaseEntity<...> { ... }
```
Once published, **these exported names are a frozen contract.**

- **The CONSUMER** — e.g. an app like Izzy. Two kinds of consumer code reference those names:
1. **Hand-written code** that imports them directly:
```ts
import { mjBizAppsCommonPersonEntity } from "@mj-biz-apps/common-entities";
const p = await md.GetEntityObject('MJ_BizApps_Common: People', user);
```
2. **The consumer's OWN `mj codegen` output**, which generates references to the producer's entity
types **by name** — and computes that name from the **consumer's local `vwEntities.ClassName`**,
i.e. from the consumer's *own database's* schema name.

**The break:** the producer computed the name once (PascalCase, when published); the consumer recomputes
it at build time from its local DB.
- On **SQL Server**, both sides compute `mjBizAppsCommonPersonEntity` (schema mixed-case in both DBs) →
**names match** → imports resolve → build passes.
- On **PostgreSQL**, the consumer's DB has the folded schema `__mj_bizappscommon`, so the consumer's
CodeGen computes **`mjbizappscommonPersonEntity`** (lowercase) and emits
`import { mjbizappscommonPersonEntity } from "@mj-biz-apps/common-entities"` — but the package only
**exports** the PascalCase `mjBizAppsCommonPersonEntity`. The imported symbol **does not exist in the
package's exports** → **TS2724**.

This is why the bug is invisible in a self-contained SS build and only appears across the package
boundary on PG: the producer's published export casing and the consumer's PG-recomputed import casing
disagree, and the linker cannot connect them.

**Why this framing decides the options:** the fix must make the two sides *agree on a casing*. Both
options below do this by making the consumer's PG CodeGen recompute **PascalCase** (via the recovered
canonical name), so it matches the **already-published producer exports** → the consumer converges to the
existing contract, with **no republish and no consumer edits**. (The opposite approach — forcing both
sides to lowercase — would require republishing every producer package with lowercase exports as a
breaking change *and* rewriting every consumer's hand-written imports, e.g. Izzy's ~400
`mjBizAppsCommon…Entity` references. That breaks the published contract ecosystem-wide and is out of
scope for this plan.)

## 2. Proven constraints (verified, not assumed — do not relitigate these)

These were each confirmed empirically (live PG, code trace, or both). Every option must live within them.

1. **PG destroys the casing at `CREATE SCHEMA` time.** `CREATE SCHEMA __mj_BizAppsCommon` (unquoted)
is stored by PG as `__mj_bizappscommon`. Verified on a live PG 17 container.

2. **PG folds identifier *names* but preserves column *data*.** The same string `__mj_BizAppsCommon`
stored as a schema name comes back lowercase; stored as a column value comes back intact. Verified
live. → *Any* solution that "keeps the canonical name in the DB" must keep it as **column data**, not
as a schema identifier.

3. **You cannot recompute canonical from physical.** `__mj_bizappscommon` → `__mj_BizAppsCommon`
requires inventing where the capital letters and word breaks were. No function can do this reliably;
a wrong guess = a broken build. (The one exception: the core `__mj` → `MJ` mapping is a hardcoded
special case in `getSchemaPrefix`, viable only because it's a single known constant.)

4. **MJ deliberately folds to lowercase at every persistence point.** This is by design, not oversight:
`migration-runner.ts` runs the schema through `GetDialect(platform).CanonicalSchemaName(name)` (→
lowercase on PG) before using it as the flyway `defaultSchema`, so the history table, the
`${flyway:defaultSchema}` placeholder substitution, and the physical DDL all land in the *same*
lowercase physical schema. This avoids the "quote-everything-forever" trap (a quoted mixed-case
schema requires every reference everywhere to be quoted, or it folds and mismatches — an unbounded,
silent failure mode). The lowercase-canonical convention is a sound, intentional robustness choice.

5. **Consequently, the mixed-case name survives in exactly two places, both OpenApp-specific:**
- the app **manifest** (`mj-app.json` `schema.name`) — a file, off-DB; **CodeGen does not read manifests**.
- the **`OpenApp` table** (`OpenApp.SchemaName`), which the install copies verbatim from the manifest
as column data → PG preserves it. CodeGen *can* read this (it's in the DB).

6. **For NON-OpenApp mixed-case PG schemas, the canonical name survives nowhere.** A hand-built customer
schema, an MJ-instance schema, or an app installed via raw migration (not `mj app install`) has no
manifest and no `OpenApp` row. The original casing existed only in the author's `CREATE SCHEMA`
statement, which PG folded. **There is no artifact to recover it from — it must be *declared*.**

### The irreducible conclusion
The casing the class names need is destroyed by PG and cannot be recomputed. It survives only for
OpenApps (manifest/`OpenApp` row). Therefore *any* fix must (a) **store** the canonical name in column
data, (b) **auto-populate** it for OpenApps from the one place it survives, and (c) provide a path to
**declare** it for non-OpenApp schemas (or accept lowercase identifiers there). The options below differ
only in *where the canonical name is stored* and *how much else has to change as a result*.

---

## 3. The options

Both options recover the canonical name from the same source (`OpenApp`/manifest — the only place it
survives, per §2.5) and produce the same end result (PascalCase class names matching the published
contract). They differ in **which column holds the canonical value** and, consequently, **what else
changes**. Each is described below in the same structure; §6 has a side-by-side table.

### Option A — Store the canonical name in a new `SchemaInfo.CanonicalSchemaName` column

`SchemaName` keeps its current meaning (the physical name as stored in the catalog — lowercase on PG).
A new nullable `CanonicalSchemaName` column holds the canonical value. The class-name / GraphQL-prefix
logic uses `CanonicalSchemaName` when set and `SchemaName` otherwise. OpenApp install and the CodeGen
metadata-sync proc populate the new column from `OpenApp.SchemaName`.

- **Where canonical is stored:** a new column (`SchemaInfo.CanonicalSchemaName`, nullable).
- **`SchemaName` meaning:** unchanged — remains the physical name all existing SQL uses.
- **Code changed:** migration (column + `vwEntities` COALESCE on the two prefix expressions + proc
backfill clause), `EntityInfo` field, one line in the TS prefix helper, an install hook (~5 files).
- **Identifier-derivation sites changed:** 2 (`vwEntities` and `getGraphQLTypeNameBase`). Other code that
reads `SchemaName` is unaffected because its meaning is unchanged.
- **SQL Server:** column is NULL there, so the prefix falls back to `SchemaName` — output unchanged
(verified byte-identical across 420 entities).
- **Non-OpenApp schemas:** column NULL → falls back to the lowercase `SchemaName` → lowercase
identifiers (functional, not PascalCase) unless the value is declared (see §4).
- **Trade-offs:** adds a column to a core metadata table. The backfill clause is added to
`spUpdateSchemaInfoFromDatabase`, which is already maintained in two dialect copies (see §5). Introduces
a second schema-name field, so readers must know which is physical vs. canonical.
- **Status:** implemented as PR #2992; verified end-to-end on PG (see §7).

### Option B — Make `SchemaName` itself the canonical name; the provider folds it to physical

`SchemaInfo.SchemaName` holds the canonical mixed-case value (sourced from `OpenApp`/manifest rather than
the catalog). The provider folds it to the physical lowercase form (`CanonicalSchemaName()`) wherever it
addresses the real database. No new column; class-name logic reads `SchemaName` directly.

- **Where canonical is stored:** the existing `SchemaName` column (re-sourced from `OpenApp` instead of
the catalog).
- **`SchemaName` meaning:** changes from "physical name" to "canonical name."
- **Code changed:** the sourcing of `SchemaName` (catalog → `OpenApp`), plus a fold-to-physical at every
site that uses `SchemaName` to address the DB: ~16 runtime-provider sites (already routed through
`QuoteSchema`) and ~80–340 CodeGen emission sites (including raw `` `${e.SchemaName}.${e.BaseView}` ``
interpolations that bypass `QuoteSchema`), some of which write the schema name into persisted generated
views/sprocs.
- **Identifier-derivation sites changed:** the same 2, plus the fold-down work above.
- **SQL Server:** `SchemaName` is already canonical on SS, so the value is unchanged there; the fold-down
is a no-op on SS but the code paths still route through it.
- **Non-OpenApp schemas:** same as A — without an `OpenApp` row, `SchemaName` would be sourced from the
catalog (lowercase) and identifiers would be lowercase unless declared (see §4).
- **Trade-offs:** no new column, and the model is uniform ("one canonical schema name; the provider
translates to each dialect's rules" — consistent with how the runtime query path already works via
`QuoteSchema`). Cost is concentrated in CodeGen's emission path; because `SchemaName` no longer matches
the physical schema, any site that uses it without folding produces a wrong-cased reference, and some of
those references are baked into persisted objects. Changing `SchemaName`'s meaning also requires
reconciling existing deployments' metadata + generated objects.
- **Status:** not implemented.

---

## 4. Open decision — non-OpenApp mixed-case PG schemas (applies to A and B)

For schemas with no manifest/`OpenApp` row, the canonical name must be **declared** (constraint §2.6).
Two sub-options:

- **4a. Declaration path:** canonical name is a metadata-sync-managed value on `SchemaInfo`
(version-controlled in a `.schema-info.json`), with an optional `mj.config.cjs` fallback for instances
not using metadata-sync. Provides PascalCase identifiers for non-OpenApp/custom schemas; requires a
declaration step and the supporting metadata-sync/config wiring.
- **4b. NULL fallback only:** ship the storage slot; OpenApps auto-fill it; non-OpenApp schemas are left
NULL → lowercase identifiers (functional, not PascalCase) unless an admin sets the value manually.
No declaration UX; non-OpenApp/custom schemas get lowercase identifiers by default.

This decision is **independent** of A-vs-B and can be made later — both A and B can start with 4b and add
4a if/when custom mixed-case PG schemas require PascalCase identifiers.

## 5. Pre-existing context: metadata-support proc duplication

The metadata-support routines (incl. `spUpdateSchemaInfoFromDatabase`) are **already** maintained twice —
SS baseline (T-SQL) + PG `metadataSupportObjects.ts` (regenerated each codegen) — for ~9 procs. This
duplication **pre-dates this work**. Option A's backfill adds one clause to one of those procs (kept in
sync); Option B would touch the same procs as part of its larger emission-path changes. Two independent
cleanups exist regardless of which option is chosen, and **neither blocks this fix**:
- move Option A's backfill into CodeGen's dialect-agnostic TS metadata-sync (`manage-metadata.ts`) so the
clause lives in exactly one place;
- single-source all ~9 procs from one definition (a CodeGenLib cleanup benefiting the whole codebase).

## 6. Options at a glance (for discussion — no recommendation)

Both options store the same canonical value (recovered from `OpenApp`/manifest) and produce the same end
result (PascalCase class names matching the published contract). They differ in **where** that value lives
and **how much else must change** as a result.

| | Option A — new `CanonicalSchemaName` column | Option B — `SchemaName` becomes canonical |
|---|---|---|
| Where canonical is stored | new nullable column | existing `SchemaName` column |
| `SchemaName` meaning | unchanged (physical) | changes to canonical |
| New column on a core table | yes | no |
| Sites changed | 2 identifier-derivation sites | the same 2, plus fold-to-physical at ~16 runtime + ~80–340 CodeGen emission sites |
| Persisted generated SQL affected | no | yes (schema name baked into some generated views/sprocs) |
| Behavior if a `SchemaName` consumer is missed | n/a (`SchemaName` meaning unchanged) | produces a wrong-cased reference |
| SQL Server output | unchanged (column NULL → fallback; verified, 420 entities) | unchanged (`SchemaName` already canonical on SS) |
| Existing-deployment reconciliation | none beyond the migration | metadata + generated objects must be reconciled to the new `SchemaName` meaning |
| Alignment with "provider owns dialect translation" | partial (provider folds for queries; identifiers read a stored column) | full (single canonical name; provider folds everywhere) |
| Implementation status | implemented (PR #2992), verified end-to-end (§7) | not implemented |

Whatever is chosen, the §4 (non-OpenApp authoring) and §5 (proc single-sourcing) items are separable
follow-ups.

## 7. Verification already performed (for Option A / PR #2992)

- **SS net-zero:** full combined migration applied to a real SS DB → `vwEntities`
ClassName/CodeName/BaseTableCodeName byte-identical across all 420 entities.
- **PG end-to-end:** virgin DB via real `mj migrate` → real `mj app install bizapps-common` → real
`mj codegen` → emitted `entity_subclasses.ts` contained `export class mjBizAppsCommonAddressEntity`
(PascalCase), zero lowercase classes. Backfill populated `SchemaInfo.CanonicalSchemaName` from the
`OpenApp` row; `vwEntities.ClassName` came out PascalCase.
- **Builds + tests:** MJCoreEntities/MJCore/MJServer compile; regression tests for
`getGraphQLTypeNameBase` pass.

## 8. Key references
- PR: #2992 (Option A, implemented)
- Guide: [`guides/POSTGRES_SCHEMA_CASING_GUIDE.md`](../guides/POSTGRES_SCHEMA_CASING_GUIDE.md)
- Canonicalization: `packages/SQLDialect/src/postgresqlDialect.ts` (`CanonicalSchemaName`),
`packages/OpenApp/Engine/src/install/migration-runner.ts:235`
- Prefix logic: `packages/MJCore/src/generic/graphqlTypeNames.ts`,
`vwEntities` in the v5.38 baseline
- Canonical name survives: `OpenApp.SchemaName` (set in
`packages/OpenApp/Engine/src/install/history-recorder.ts:96`)

Contributor guide

Open the contributing guide

Research direction

Start by reviewing PR #2992 and the described entry points: migration-runner.ts, SchemaInfo, EntityInfo, vwEntities, spUpdateSchemaInfoFromDatabase, and getGraphQLTypeNameBase. Compare Option A with Option B and resolve the open non-OpenApp declaration choice. Done means PostgreSQL consumers converge on the published entity names without breaking existing SQL Server behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
graphql, postgresql, sql, typescript
Domain
backend-api-design, databases, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.