@@ -23,21 +23,6 @@ import {
2323 RELATIONSHIP_SUBTYPE_ASSOCIATION ,
2424 FIELD_SUBTYPE_ENUM ,
2525 FIELD_SUBTYPE_OBJECT ,
26- FIELD_SUBTYPE_STRING ,
27- FIELD_SUBTYPE_CLASS ,
28- FIELD_SUBTYPE_UUID ,
29- FIELD_SUBTYPE_INT ,
30- FIELD_SUBTYPE_SHORT ,
31- FIELD_SUBTYPE_BYTE ,
32- FIELD_SUBTYPE_LONG ,
33- FIELD_SUBTYPE_DOUBLE ,
34- FIELD_SUBTYPE_FLOAT ,
35- FIELD_SUBTYPE_DECIMAL ,
36- FIELD_SUBTYPE_CURRENCY ,
37- FIELD_SUBTYPE_BOOLEAN ,
38- FIELD_SUBTYPE_DATE ,
39- FIELD_SUBTYPE_TIME ,
40- FIELD_SUBTYPE_TIMESTAMP ,
4126 FIELD_ATTR_REQUIRED ,
4227 FIELD_ATTR_UNIQUE ,
4328 FIELD_ATTR_OBJECT_REF ,
@@ -51,11 +36,11 @@ import {
5136 VALIDATOR_ATTR_MIN ,
5237 VALIDATOR_ATTR_MAX ,
5338 DOC_ATTR_DESCRIPTION ,
39+ FIELD_ATTR_DB_COLUMN_TYPE ,
5440 stripPackage ,
5541} from "@metaobjectsdev/metadata" ;
56- import { mapColumnType , type Dialect } from "../column-mapper.js" ;
42+ import type { Dialect } from "../column-mapper.js" ;
5743import type { ColumnNamingStrategy } from "../metaobjects-config.js" ;
58- import { toPascalCase } from "../naming.js" ;
5944import { enumValues } from "../enum-meta.js" ;
6045import { hasWritableRdbSource } from "../source-detect.js" ;
6146import { GENERATED_HEADER } from "../constants.js" ;
@@ -74,93 +59,15 @@ export interface BuildDocDataOpts {
7459 loadedRoot : MetaRoot ;
7560}
7661
77- const SCALAR_TS_BY_SUBTYPE : Record < string , string > = {
78- [ FIELD_SUBTYPE_STRING ] : "string" ,
79- [ FIELD_SUBTYPE_CLASS ] : "string" ,
80- [ FIELD_SUBTYPE_UUID ] : "string" ,
81- [ FIELD_SUBTYPE_INT ] : "number" ,
82- [ FIELD_SUBTYPE_SHORT ] : "number" ,
83- [ FIELD_SUBTYPE_BYTE ] : "number" ,
84- [ FIELD_SUBTYPE_LONG ] : "number" ,
85- [ FIELD_SUBTYPE_DOUBLE ] : "number" ,
86- [ FIELD_SUBTYPE_FLOAT ] : "number" ,
87- // field.decimal is precision-exact: surfaced as a TS `string` (Drizzle pg
88- // `numeric` infers `string`). Keep the docs scalar mapping in lockstep.
89- [ FIELD_SUBTYPE_DECIMAL ] : "string" ,
90- [ FIELD_SUBTYPE_CURRENCY ] : "number" ,
91- [ FIELD_SUBTYPE_BOOLEAN ] : "boolean" ,
92- [ FIELD_SUBTYPE_DATE ] : "string" ,
93- [ FIELD_SUBTYPE_TIME ] : "string" ,
94- [ FIELD_SUBTYPE_TIMESTAMP ] : "string" ,
95- } ;
96-
97- function enumTypeAliasName ( entity : MetaObject , field : MetaField ) : string {
98- const superField = field . resolveSuper ( ) ;
99- return superField !== undefined
100- ? toPascalCase ( superField . name )
101- : `${ entity . name } ${ toPascalCase ( field . name ) } ` ;
102- }
103-
10462function isFieldRequired ( field : MetaField ) : boolean {
10563 if ( field . ownAttr ( FIELD_ATTR_REQUIRED ) === true ) return true ;
10664 return field . validators ( ) . some ( ( v ) => v . subType === VALIDATOR_SUBTYPE_REQUIRED ) ;
10765}
10866
109- function tsTypeForStorage (
110- entity : MetaObject ,
111- field : MetaField ,
112- pkFieldNames : ReadonlySet < string > ,
113- ) : string {
114- let base : string ;
115-
116- if ( field . subType === FIELD_SUBTYPE_ENUM ) {
117- const values = enumValues ( field ) ;
118- if ( values !== undefined && values . length > 0 ) {
119- if ( field . isArray ) {
120- base = `${ enumTypeAliasName ( entity , field ) } []` ;
121- } else {
122- base = values . map ( ( v ) => JSON . stringify ( v ) ) . join ( " | " ) ;
123- }
124- } else {
125- base = field . isArray ? "string[]" : "string" ;
126- }
127- } else if ( field . subType === FIELD_SUBTYPE_OBJECT ) {
128- const ref = field . ownAttr ( FIELD_ATTR_OBJECT_REF ) ;
129- const refName = typeof ref === "string" && ref . length > 0 ? ref : "unknown" ;
130- base = field . isArray ? `${ refName } []` : refName ;
131- } else {
132- const scalar = SCALAR_TS_BY_SUBTYPE [ field . subType ] ?? "unknown" ;
133- base = field . isArray ? `${ scalar } []` : scalar ;
134- }
135-
136- const required = pkFieldNames . has ( field . name ) || isFieldRequired ( field ) ;
137- return required ? base : `${ base } | null` ;
138- }
139-
140- function sqlColumnExpr ( spec : ReturnType < typeof mapColumnType > ) : string {
141- const dbName = JSON . stringify ( spec . dbName ) ;
142- if ( spec . fnOptions !== undefined && Object . keys ( spec . fnOptions ) . length > 0 ) {
143- const parts : string [ ] = [ ] ;
144- for ( const [ k , v ] of Object . entries ( spec . fnOptions ) ) {
145- const lit = JSON . stringify ( v ) ;
146- if ( Array . isArray ( v ) ) {
147- parts . push ( `${ k } : ${ lit } as const` ) ;
148- } else {
149- parts . push ( `${ k } : ${ lit } ` ) ;
150- }
151- }
152- return `${ spec . fnName } (${ dbName } , { ${ parts . join ( ", " ) } })` ;
153- }
154- return `${ spec . fnName } (${ dbName } )` ;
155- }
156-
157- /** The raw validator/limit facts shared by both constraint walks. Walks the
158- * field's validators ONCE, bucketed by subtype, plus the `@maxLength` attr.
159- * Both `constraintsCell()` (Storage cell) and `buildConstraintRow()`
160- * (Constraints row) consume these — the SINGLE source of truth for the
161- * validator emission, so the two presentations can't DRIFT. Each caller
162- * arranges the parts into its own layout (one cell vs. limits/rules columns)
163- * but the emission ORDER and exact strings come from here:
67+ /** The raw validator/limit facts for the Constraints table. Walks the field's
68+ * validators ONCE, bucketed by subtype, plus the `@maxLength` attr.
69+ * `buildConstraintRow()` consumes these — the SINGLE source of truth for the
70+ * validator emission. The emission ORDER and exact strings come from here:
16471 * regex pattern → maxLength-from-@maxLength → length-validator (min/max) →
16572 * numeric-validator (min/max). */
16673interface ValidatorParts {
@@ -205,73 +112,13 @@ function collectValidatorParts(field: MetaField): ValidatorParts {
205112 } ;
206113}
207114
208- function constraintsCell (
209- entity : MetaObject ,
210- field : MetaField ,
211- pkFieldNames : Set < string > ,
212- fkMap : Map < string , { targetEntity : string ; targetField : string } > ,
213- ) : string {
214- const parts : string [ ] = [ ] ;
215-
216- if ( pkFieldNames . has ( field . name ) ) {
217- parts . push ( "primary key" ) ;
218- const primary = entity . primaryIdentity ( ) ;
219- const gen = primary ?. ownAttr ( IDENTITY_ATTR_GENERATION ) ;
220- if ( typeof gen === "string" ) {
221- parts . push ( `generation: \`${ gen } \`` ) ;
222- }
223- } else if ( isFieldRequired ( field ) ) {
224- parts . push ( "required" ) ;
225- } else {
226- parts . push ( "optional" ) ;
227- }
228-
229- if ( field . ownAttr ( FIELD_ATTR_UNIQUE ) === true ) {
230- parts . push ( "unique" ) ;
231- }
232-
233- if ( field . isArray ) {
234- parts . push ( "JSON column" ) ;
235- }
236-
237- if ( field . subType === FIELD_SUBTYPE_ENUM && ! field . isArray ) {
238- const values = enumValues ( field ) ;
239- if ( values !== undefined && values . length > 0 ) {
240- const list = values . map ( ( v ) => `'${ v . replace ( / ' / g, "''" ) } '` ) . join ( ", " ) ;
241- parts . push ( `CHECK \`${ field . column ?? field . name } IN (${ list } )\`` ) ;
242- }
243- }
244-
245- const { maxLenAttr, regexParts, lengthParts, numericParts } = collectValidatorParts ( field ) ;
246- parts . push ( ...regexParts ) ;
247- if ( maxLenAttr !== undefined ) {
248- parts . push ( `maxLength: ${ maxLenAttr } ` ) ;
249- }
250- parts . push ( ...lengthParts , ...numericParts ) ;
251-
252- const fk = fkMap . get ( field . name ) ;
253- if ( fk !== undefined ) {
254- parts . push ( `references \`${ fk . targetEntity } .${ fk . targetField } \`` ) ;
255- }
256-
257- const def = field . ownAttr ( FIELD_ATTR_DEFAULT ) ;
258- if ( def !== undefined ) {
259- parts . push ( `default: \`${ String ( def ) } \`` ) ;
260- }
261-
262- const sup = field . resolveSuper ( ) ;
263- if ( sup !== undefined ) {
264- parts . push ( `extends \`${ sup . name } \`` ) ;
265- }
266-
267- return parts . join ( ", " ) ;
268- }
269-
270- /** Neutral logical type cell for the Constraints table. Unlike the Storage
271- * table's TypeScript type, this is language-agnostic: the field's logical
115+ /** The NEUTRAL logical type string (no backticks): the field's logical
272116 * subtype (e.g. `string`, `enum`, `decimal`), suffixed `[]` for arrays, and
273- * the referenced object name for `field.object`. Wrapped in backticks. */
274- function neutralTypeCell ( field : MetaField ) : string {
117+ * the referenced object name for `field.object`. Language-agnostic — built
118+ * from declared metadata, never re-derived into ANSI/ORM SQL. Shared by the
119+ * Constraints table (`neutralTypeCell`) and the Storage table's physical-type
120+ * fallback (`storageTypeCell`). */
121+ function neutralTypeStr ( field : MetaField ) : string {
275122 let base : string ;
276123 if ( field . subType === FIELD_SUBTYPE_OBJECT ) {
277124 const ref = field . ownAttr ( FIELD_ATTR_OBJECT_REF ) ;
@@ -280,7 +127,27 @@ function neutralTypeCell(field: MetaField): string {
280127 base = field . subType ;
281128 }
282129 if ( field . isArray ) base = `${ base } []` ;
283- return `\`${ base } \`` ;
130+ return base ;
131+ }
132+
133+ /** Neutral logical type cell for the Constraints table — `neutralTypeStr`
134+ * wrapped in backticks. */
135+ function neutralTypeCell ( field : MetaField ) : string {
136+ return `\`${ neutralTypeStr ( field ) } \`` ;
137+ }
138+
139+ /** Neutral PHYSICAL type cell for the Storage table. Metadata-driven, no DDL
140+ * re-derivation (ADR-0020): if the field declares a `@dbColumnType` physical
141+ * override (e.g. `uuid`, `jsonb`, `timestamp_with_tz`) show it UPPERCASED;
142+ * otherwise fall back to the same neutral LOGICAL type the Constraints table
143+ * uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
144+ * migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
145+ function storageTypeCell ( field : MetaField ) : string {
146+ const dbColumnType = field . ownAttr ( FIELD_ATTR_DB_COLUMN_TYPE ) ;
147+ if ( typeof dbColumnType === "string" && dbColumnType . length > 0 ) {
148+ return `\`${ dbColumnType . toUpperCase ( ) } \`` ;
149+ }
150+ return `\`${ neutralTypeStr ( field ) } \`` ;
284151}
285152
286153/** Build one neutral Constraints-table row for a field. Reuses the same
@@ -420,28 +287,46 @@ export function buildEntityDocData(
420287 entity : MetaObject ,
421288 opts : BuildDocDataOpts ,
422289) : EntityDocData {
423- const strategy = opts . columnNamingStrategy ?? "snake_case" ;
424290 const root = opts . loadedRoot ;
425291 const primary = entity . primaryIdentity ( ) ;
426292 const pkFields = primary ?. fields ?? [ ] ;
427293 const pkFieldNames = new Set < string > ( pkFields ) ;
428294 const fkMap = buildFkMap ( entity , root ) ;
429295
430- // ---- Storage rows
296+ // ---- Storage rows — NEUTRAL physical persistence MAPPING (ADR-0020): the
297+ // physical column name, a neutral physical type (declared `@dbColumnType`
298+ // override else the logical type), nullability, and the key role. NO
299+ // TypeScript type, NO ORM DDL, NO ANSI re-derivation — declared metadata
300+ // facts only. The value-add over the Constraints table is the field→column
301+ // mapping + physical-type override + key role.
431302 const storageRows : StorageFieldDoc [ ] = entity . fields ( ) . map ( ( field ) => {
432- const spec = mapColumnType ( field , opts . dialect , strategy ) ;
433- const tsType = tsTypeForStorage ( entity , field , pkFieldNames ) ;
434- const tsTypeCell = tsType . split ( "|" ) . map ( ( s ) => s . trim ( ) ) . join ( " \\| " ) ;
435- const sqlExpr = sqlColumnExpr ( spec ) ;
436- const cons = constraintsCell ( entity , field , pkFieldNames , fkMap ) ;
437- const tsTypeCellStr = `\`${ tsTypeCell } \`` ;
438- const sqlExprCellStr = `\`${ sqlExpr } \`` ;
303+ const isPk = pkFieldNames . has ( field . name ) ;
304+ // Physical column name: the field's `@column` override if set, else the
305+ // field name. (The Storage section shows the RAW declared mapping; column
306+ // naming-strategy folding stays a codegen concern, not a docs fact.)
307+ const columnName = field . column ?? field . name ;
308+ const columnCell = `\`${ columnName } \`` ;
309+ const typeCell = storageTypeCell ( field ) ;
310+ // Nullable iff not required and not the PK (matches the Constraints table's
311+ // required-ness rule).
312+ const nullable = ! ( isPk || isFieldRequired ( field ) ) ;
313+ const nullableCell = nullable ? "yes" : "no" ;
314+
315+ let keyCell = "" ;
316+ if ( isPk ) {
317+ keyCell = "primary key" ;
318+ } else {
319+ const fk = fkMap . get ( field . name ) ;
320+ if ( fk !== undefined ) keyCell = `foreign key → \`${ fk . targetEntity } \`` ;
321+ }
322+
439323 return {
440324 name : field . name ,
441- tsTypeCell : tsTypeCellStr ,
442- sqlExprCell : sqlExprCellStr ,
443- constraintsCell : cons ,
444- rowLine : `| \`${ field . name } \` | ${ tsTypeCellStr } | ${ sqlExprCellStr } | ${ cons } |` ,
325+ columnCell,
326+ typeCell,
327+ nullableCell,
328+ keyCell,
329+ rowLine : `| ${ columnCell } | ${ typeCell } | ${ nullableCell } | ${ keyCell } |` ,
445330 } ;
446331 } ) ;
447332
@@ -523,7 +408,7 @@ export function buildEntityDocData(
523408
524409 if ( hasStorage ) {
525410 data . storage = {
526- tableHeader : "| Field | TypeScript type | SQL column | Constraints |\n|---|---|---|---|" ,
411+ tableHeader : "| Column | Type | Nullable | Key |\n|---|---|---|---|" ,
527412 rows : storageRows ,
528413 } ;
529414 data . hasStorage = true ;
0 commit comments