@metaobjectsdev/codegen-ts 0.5.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +189 -0
- package/README.md +101 -0
- package/dist/column-mapper.d.ts +38 -0
- package/dist/column-mapper.d.ts.map +1 -0
- package/dist/column-mapper.js +205 -0
- package/dist/column-mapper.js.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +47 -0
- package/dist/format.js.map +1 -0
- package/dist/generator.d.ts +44 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +17 -0
- package/dist/generator.js.map +1 -0
- package/dist/generators/barrel.d.ts +6 -0
- package/dist/generators/barrel.d.ts.map +1 -0
- package/dist/generators/barrel.js +17 -0
- package/dist/generators/barrel.js.map +1 -0
- package/dist/generators/entity-file.d.ts +8 -0
- package/dist/generators/entity-file.d.ts.map +1 -0
- package/dist/generators/entity-file.js +27 -0
- package/dist/generators/entity-file.js.map +1 -0
- package/dist/generators/index.d.ts +5 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +5 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/queries-file.d.ts +8 -0
- package/dist/generators/queries-file.d.ts.map +1 -0
- package/dist/generators/queries-file.js +26 -0
- package/dist/generators/queries-file.js.map +1 -0
- package/dist/generators/routes-file.d.ts +12 -0
- package/dist/generators/routes-file.d.ts.map +1 -0
- package/dist/generators/routes-file.js +30 -0
- package/dist/generators/routes-file.js.map +1 -0
- package/dist/import-path.d.ts +41 -0
- package/dist/import-path.d.ts.map +1 -0
- package/dist/import-path.js +95 -0
- package/dist/import-path.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/metaobjects-config.d.ts +56 -0
- package/dist/metaobjects-config.d.ts.map +1 -0
- package/dist/metaobjects-config.js +42 -0
- package/dist/metaobjects-config.js.map +1 -0
- package/dist/naming.d.ts +29 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +67 -0
- package/dist/naming.js.map +1 -0
- package/dist/overwrite-policy.d.ts +8 -0
- package/dist/overwrite-policy.d.ts.map +1 -0
- package/dist/overwrite-policy.js +23 -0
- package/dist/overwrite-policy.js.map +1 -0
- package/dist/pk-resolver.d.ts +18 -0
- package/dist/pk-resolver.d.ts.map +1 -0
- package/dist/pk-resolver.js +36 -0
- package/dist/pk-resolver.js.map +1 -0
- package/dist/projection/extract-view-spec.d.ts +18 -0
- package/dist/projection/extract-view-spec.d.ts.map +1 -0
- package/dist/projection/extract-view-spec.js +272 -0
- package/dist/projection/extract-view-spec.js.map +1 -0
- package/dist/projection/index.d.ts +5 -0
- package/dist/projection/index.d.ts.map +1 -0
- package/dist/projection/index.js +5 -0
- package/dist/projection/index.js.map +1 -0
- package/dist/projection/projection-detector.d.ts +4 -0
- package/dist/projection/projection-detector.d.ts.map +1 -0
- package/dist/projection/projection-detector.js +13 -0
- package/dist/projection/projection-detector.js.map +1 -0
- package/dist/projection/view-ddl-emit.d.ts +10 -0
- package/dist/projection/view-ddl-emit.d.ts.map +1 -0
- package/dist/projection/view-ddl-emit.js +47 -0
- package/dist/projection/view-ddl-emit.js.map +1 -0
- package/dist/projection/view-spec.d.ts +56 -0
- package/dist/projection/view-spec.d.ts.map +1 -0
- package/dist/projection/view-spec.js +2 -0
- package/dist/projection/view-spec.js.map +1 -0
- package/dist/relation-resolver.d.ts +21 -0
- package/dist/relation-resolver.d.ts.map +1 -0
- package/dist/relation-resolver.js +62 -0
- package/dist/relation-resolver.js.map +1 -0
- package/dist/render-context.d.ts +65 -0
- package/dist/render-context.d.ts.map +1 -0
- package/dist/render-context.js +28 -0
- package/dist/render-context.js.map +1 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +135 -0
- package/dist/runner.js.map +1 -0
- package/dist/templates/barrel.d.ts +8 -0
- package/dist/templates/barrel.d.ts.map +1 -0
- package/dist/templates/barrel.js +12 -0
- package/dist/templates/barrel.js.map +1 -0
- package/dist/templates/drizzle-schema.d.ts +13 -0
- package/dist/templates/drizzle-schema.d.ts.map +1 -0
- package/dist/templates/drizzle-schema.js +251 -0
- package/dist/templates/drizzle-schema.js.map +1 -0
- package/dist/templates/entity-constants.d.ts +4 -0
- package/dist/templates/entity-constants.d.ts.map +1 -0
- package/dist/templates/entity-constants.js +215 -0
- package/dist/templates/entity-constants.js.map +1 -0
- package/dist/templates/entity-file.d.ts +4 -0
- package/dist/templates/entity-file.d.ts.map +1 -0
- package/dist/templates/entity-file.js +45 -0
- package/dist/templates/entity-file.js.map +1 -0
- package/dist/templates/field-meta.d.ts +24 -0
- package/dist/templates/field-meta.d.ts.map +1 -0
- package/dist/templates/field-meta.js +117 -0
- package/dist/templates/field-meta.js.map +1 -0
- package/dist/templates/filter-allowlist.d.ts +5 -0
- package/dist/templates/filter-allowlist.d.ts.map +1 -0
- package/dist/templates/filter-allowlist.js +86 -0
- package/dist/templates/filter-allowlist.js.map +1 -0
- package/dist/templates/filter-shared.d.ts +15 -0
- package/dist/templates/filter-shared.d.ts.map +1 -0
- package/dist/templates/filter-shared.js +30 -0
- package/dist/templates/filter-shared.js.map +1 -0
- package/dist/templates/filter-type.d.ts +4 -0
- package/dist/templates/filter-type.d.ts.map +1 -0
- package/dist/templates/filter-type.js +78 -0
- package/dist/templates/filter-type.js.map +1 -0
- package/dist/templates/inferred-types.d.ts +4 -0
- package/dist/templates/inferred-types.d.ts.map +1 -0
- package/dist/templates/inferred-types.js +14 -0
- package/dist/templates/inferred-types.js.map +1 -0
- package/dist/templates/projection-decl.d.ts +21 -0
- package/dist/templates/projection-decl.d.ts.map +1 -0
- package/dist/templates/projection-decl.js +116 -0
- package/dist/templates/projection-decl.js.map +1 -0
- package/dist/templates/queries-file.d.ts +4 -0
- package/dist/templates/queries-file.d.ts.map +1 -0
- package/dist/templates/queries-file.js +39 -0
- package/dist/templates/queries-file.js.map +1 -0
- package/dist/templates/queries.d.ts +9 -0
- package/dist/templates/queries.d.ts.map +1 -0
- package/dist/templates/queries.js +115 -0
- package/dist/templates/queries.js.map +1 -0
- package/dist/templates/relations-block.d.ts +9 -0
- package/dist/templates/relations-block.d.ts.map +1 -0
- package/dist/templates/relations-block.js +45 -0
- package/dist/templates/relations-block.js.map +1 -0
- package/dist/templates/routes-file.d.ts +4 -0
- package/dist/templates/routes-file.d.ts.map +1 -0
- package/dist/templates/routes-file.js +158 -0
- package/dist/templates/routes-file.js.map +1 -0
- package/dist/templates/zod-validators.d.ts +4 -0
- package/dist/templates/zod-validators.d.ts.map +1 -0
- package/dist/templates/zod-validators.js +129 -0
- package/dist/templates/zod-validators.js.map +1 -0
- package/package.json +59 -0
- package/src/column-mapper.ts +266 -0
- package/src/constants.ts +10 -0
- package/src/errors.ts +10 -0
- package/src/format.ts +50 -0
- package/src/generator.ts +73 -0
- package/src/generators/barrel.ts +28 -0
- package/src/generators/entity-file.ts +33 -0
- package/src/generators/index.ts +4 -0
- package/src/generators/queries-file.ts +32 -0
- package/src/generators/routes-file.ts +36 -0
- package/src/import-path.ts +153 -0
- package/src/index.ts +45 -0
- package/src/metaobjects-config.ts +95 -0
- package/src/naming.ts +84 -0
- package/src/overwrite-policy.ts +39 -0
- package/src/pk-resolver.ts +47 -0
- package/src/projection/extract-view-spec.ts +372 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/projection-detector.ts +26 -0
- package/src/projection/view-ddl-emit.ts +66 -0
- package/src/projection/view-spec.ts +62 -0
- package/src/relation-resolver.ts +87 -0
- package/src/render-context.ts +93 -0
- package/src/runner.ts +178 -0
- package/src/templates/barrel.ts +23 -0
- package/src/templates/drizzle-schema.ts +286 -0
- package/src/templates/entity-constants.ts +248 -0
- package/src/templates/entity-file.ts +51 -0
- package/src/templates/field-meta.ts +150 -0
- package/src/templates/filter-allowlist.ts +104 -0
- package/src/templates/filter-shared.ts +30 -0
- package/src/templates/filter-type.ts +93 -0
- package/src/templates/inferred-types.ts +16 -0
- package/src/templates/projection-decl.ts +146 -0
- package/src/templates/queries-file.ts +56 -0
- package/src/templates/queries.ts +132 -0
- package/src/templates/relations-block.ts +65 -0
- package/src/templates/routes-file.ts +179 -0
- package/src/templates/zod-validators.ts +140 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// RenderContext — cross-cutting state passed to every template.
|
|
2
|
+
|
|
3
|
+
import type { MetaRoot } from "@metaobjectsdev/metadata";
|
|
4
|
+
import type { Dialect } from "./column-mapper.js";
|
|
5
|
+
import type { PkInfo } from "./pk-resolver.js";
|
|
6
|
+
import type { RelationMap } from "./relation-resolver.js";
|
|
7
|
+
import type { ColumnNamingStrategy } from "./metaobjects-config.js";
|
|
8
|
+
import type { OutputLayout, ResolvedTarget } from "./import-path.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How to format cross-entity import specifiers in generated files.
|
|
12
|
+
* - "none" → emit `"./Foo"`. **Default.** Works for moduleResolution
|
|
13
|
+
* "bundler"/"node", tsx, drizzle-kit's TS loader, Vite, esbuild,
|
|
14
|
+
* and almost every modern TS toolchain.
|
|
15
|
+
* - "js" → emit `"./Foo.js"`. Required for Node ESM strict + TS NodeNext.
|
|
16
|
+
* Opt in via `--ext-style js` or `codegen.extStyle = "js"`.
|
|
17
|
+
*/
|
|
18
|
+
export type ExtStyle = "js" | "none";
|
|
19
|
+
|
|
20
|
+
export interface RenderContext {
|
|
21
|
+
dialect: Dialect;
|
|
22
|
+
loadedRoot: MetaRoot;
|
|
23
|
+
outDir: string;
|
|
24
|
+
/**
|
|
25
|
+
* Import path for { db } in generated .queries.ts files.
|
|
26
|
+
* E.g. '~/server/db'. Set via forge.config.ts codegen.dbImport.
|
|
27
|
+
*/
|
|
28
|
+
dbImport: string;
|
|
29
|
+
/**
|
|
30
|
+
* Import path for { om } in generated .routes.ts files.
|
|
31
|
+
* E.g. '../index' or '@your-pkg/database'. The module is expected to export
|
|
32
|
+
* an `om()` function returning a Promise<ObjectManager>.
|
|
33
|
+
* Defaults to '../index' (one level up from outDir).
|
|
34
|
+
*/
|
|
35
|
+
omImport: string;
|
|
36
|
+
/** Cross-entity import-specifier style. Defaults to "js" (Node-ESM-safe). */
|
|
37
|
+
extStyle: ExtStyle;
|
|
38
|
+
/** Column naming strategy: how field names map to DB column names. Defaults to "snake_case". */
|
|
39
|
+
columnNamingStrategy: ColumnNamingStrategy;
|
|
40
|
+
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
|
|
41
|
+
apiPrefix: string;
|
|
42
|
+
/** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
|
|
43
|
+
outputLayout: OutputLayout;
|
|
44
|
+
/** The target THIS generator emits to (drives path layout + same-target imports). */
|
|
45
|
+
selfTarget: ResolvedTarget;
|
|
46
|
+
/** Where entity files live (drives cross-target entity imports). */
|
|
47
|
+
entityModuleTarget: ResolvedTarget;
|
|
48
|
+
pkMap: Map<string, PkInfo>;
|
|
49
|
+
/** Pre-pass relation map for FK + relations() block emission. */
|
|
50
|
+
relationMap: RelationMap;
|
|
51
|
+
/** Entity name → its metadata package (undefined if the entity has no package). Built once per run. */
|
|
52
|
+
packageOf: Map<string, string | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). */
|
|
56
|
+
export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
|
|
57
|
+
extStyle?: ExtStyle;
|
|
58
|
+
omImport?: string;
|
|
59
|
+
columnNamingStrategy?: ColumnNamingStrategy;
|
|
60
|
+
apiPrefix?: string;
|
|
61
|
+
outputLayout?: OutputLayout;
|
|
62
|
+
packageOf?: Map<string, string | undefined>;
|
|
63
|
+
selfTarget?: ResolvedTarget;
|
|
64
|
+
entityModuleTarget?: ResolvedTarget;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Append the configured extension to a cross-entity module specifier. */
|
|
68
|
+
export function withExt(spec: string, style: ExtStyle): string {
|
|
69
|
+
return style === "js" ? `${spec}.js` : spec;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Thin factory; applies sensible defaults for fields the caller may omit. */
|
|
73
|
+
export function makeRenderContext(opts: RenderContextInput): RenderContext {
|
|
74
|
+
const outputLayout = opts.outputLayout ?? "flat";
|
|
75
|
+
const defaultTarget: ResolvedTarget = opts.selfTarget ?? {
|
|
76
|
+
name: "default",
|
|
77
|
+
outDir: opts.outDir,
|
|
78
|
+
importBase: undefined,
|
|
79
|
+
outputLayout,
|
|
80
|
+
dbImport: opts.dbImport,
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
...opts,
|
|
84
|
+
extStyle: opts.extStyle ?? "none",
|
|
85
|
+
omImport: opts.omImport ?? "../index",
|
|
86
|
+
columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
|
|
87
|
+
apiPrefix: opts.apiPrefix ?? "",
|
|
88
|
+
outputLayout,
|
|
89
|
+
packageOf: opts.packageOf ?? new Map(),
|
|
90
|
+
selfTarget: defaultTarget,
|
|
91
|
+
entityModuleTarget: opts.entityModuleTarget ?? defaultTarget,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
|
|
3
|
+
import { MetaRoot } from "@metaobjectsdev/metadata";
|
|
4
|
+
import type { Generator, GenContext, EmittedFile } from "./generator.js";
|
|
5
|
+
import type { MetaobjectsGenConfig } from "./metaobjects-config.js";
|
|
6
|
+
import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js";
|
|
7
|
+
import type { ResolvedTarget } from "./import-path.js";
|
|
8
|
+
import { buildPkMap } from "./pk-resolver.js";
|
|
9
|
+
import { buildRelationMap } from "./relation-resolver.js";
|
|
10
|
+
import { makeRenderContext } from "./render-context.js";
|
|
11
|
+
import { decideAndWrite, type WriteResult, type MergeStrategy } from "./overwrite-policy.js";
|
|
12
|
+
|
|
13
|
+
/** JS-identifier-shape only. Prevents filesystem traversal when metadata comes
|
|
14
|
+
* from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */
|
|
15
|
+
const VALID_ENTITY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
export interface RunGenOpts {
|
|
18
|
+
config: MetaobjectsGenConfig;
|
|
19
|
+
metadata: MetaData;
|
|
20
|
+
/** Optional whitelist of entity names. */
|
|
21
|
+
entityFilter?: string[];
|
|
22
|
+
/** Overwrite strategy passed to decideAndWrite. Defaults to "overwrite". */
|
|
23
|
+
mergeStrategy?: MergeStrategy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunGenResult {
|
|
27
|
+
files: WriteResult[];
|
|
28
|
+
warnings: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
|
|
32
|
+
const warnings: string[] = [];
|
|
33
|
+
const strategy = opts.mergeStrategy ?? "overwrite";
|
|
34
|
+
|
|
35
|
+
// loadMemory now returns MetaRoot; guard here also covers callers that pass a
|
|
36
|
+
// plain MetaData (e.g. test helpers that build trees programmatically).
|
|
37
|
+
if (!(opts.metadata instanceof MetaRoot)) {
|
|
38
|
+
throw new Error("runGen: opts.metadata must be a loaded MetaRoot.");
|
|
39
|
+
}
|
|
40
|
+
const root = opts.metadata;
|
|
41
|
+
|
|
42
|
+
// 1. Resolve entities (filter + safety check).
|
|
43
|
+
const allObjects = root.objects();
|
|
44
|
+
const entityFilter = opts.entityFilter;
|
|
45
|
+
const filtered = entityFilter
|
|
46
|
+
? allObjects.filter((o) => entityFilter.includes(o.name))
|
|
47
|
+
: allObjects;
|
|
48
|
+
if (filtered.length === 0) {
|
|
49
|
+
const reason = opts.entityFilter
|
|
50
|
+
? "no object children match the provided entityFilter"
|
|
51
|
+
: "root has no object children";
|
|
52
|
+
warnings.push(`No entities to generate — ${reason}.`);
|
|
53
|
+
return { files: [], warnings };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const safeEntities: MetaObject[] = [];
|
|
57
|
+
for (const entity of filtered) {
|
|
58
|
+
if (!VALID_ENTITY_NAME.test(entity.name)) {
|
|
59
|
+
warnings.push(
|
|
60
|
+
`Skipping entity with unsafe name "${entity.name}" — must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
|
|
61
|
+
);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
safeEntities.push(entity);
|
|
65
|
+
}
|
|
66
|
+
if (safeEntities.length === 0) {
|
|
67
|
+
return { files: [], warnings };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Resolve targets + entity-module target.
|
|
71
|
+
const config = normalizeConfig(opts.config);
|
|
72
|
+
const targets = config.targets;
|
|
73
|
+
const targetOf = (g: Generator): ResolvedTarget => {
|
|
74
|
+
const name = g.target ?? DEFAULT_TARGET_NAME;
|
|
75
|
+
const t = targets[name];
|
|
76
|
+
if (!t) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Generator "${g.name}" references unknown target "${name}". ` +
|
|
79
|
+
`Valid targets: ${Object.keys(targets).join(", ")}.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return t;
|
|
83
|
+
};
|
|
84
|
+
// Validate all target references up front.
|
|
85
|
+
for (const g of config.generators) targetOf(g);
|
|
86
|
+
|
|
87
|
+
const entityGen = config.generators.find((g) => g.emitsEntityModule);
|
|
88
|
+
const entityModuleTarget = entityGen ? targetOf(entityGen) : targets[DEFAULT_TARGET_NAME]!;
|
|
89
|
+
|
|
90
|
+
const needsCrossTarget = config.generators.some(
|
|
91
|
+
(g) => (g.target ?? DEFAULT_TARGET_NAME) !== entityModuleTarget.name,
|
|
92
|
+
);
|
|
93
|
+
if (needsCrossTarget && entityModuleTarget.importBase === undefined) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Target "${entityModuleTarget.name}" holds the entity modules that other ` +
|
|
96
|
+
`targets import, but has no importBase. Set importBase on it (e.g. ` +
|
|
97
|
+
`"@your-pkg/database/generated").`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Build shared render state once.
|
|
102
|
+
const pkMap = buildPkMap(root);
|
|
103
|
+
const relationMap = buildRelationMap(root);
|
|
104
|
+
const packageOf = new Map<string, string | undefined>(
|
|
105
|
+
root.objects().map((o) => [o.name, o.package]),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// 4. Run each generator with a per-target render context; collect with full path.
|
|
109
|
+
const emitted: { fullPath: string; content: string; generatedBy: string }[] = [];
|
|
110
|
+
for (const generator of config.generators) {
|
|
111
|
+
const selfTarget = targetOf(generator);
|
|
112
|
+
const renderContext = makeRenderContext({
|
|
113
|
+
dialect: config.dialect,
|
|
114
|
+
loadedRoot: root,
|
|
115
|
+
outDir: selfTarget.outDir,
|
|
116
|
+
dbImport: selfTarget.dbImport,
|
|
117
|
+
extStyle: config.extStyle,
|
|
118
|
+
columnNamingStrategy: config.columnNamingStrategy,
|
|
119
|
+
apiPrefix: config.apiPrefix,
|
|
120
|
+
outputLayout: selfTarget.outputLayout,
|
|
121
|
+
pkMap,
|
|
122
|
+
relationMap,
|
|
123
|
+
packageOf,
|
|
124
|
+
selfTarget,
|
|
125
|
+
entityModuleTarget,
|
|
126
|
+
});
|
|
127
|
+
const ctx: GenContext = {
|
|
128
|
+
entities: safeEntities,
|
|
129
|
+
loadedRoot: root,
|
|
130
|
+
matches: (e) => generator.filter?.(e) ?? true,
|
|
131
|
+
config: {
|
|
132
|
+
outDir: selfTarget.outDir,
|
|
133
|
+
extStyle: config.extStyle,
|
|
134
|
+
dbImport: selfTarget.dbImport,
|
|
135
|
+
dialect: config.dialect,
|
|
136
|
+
outputLayout: selfTarget.outputLayout,
|
|
137
|
+
},
|
|
138
|
+
renderContext,
|
|
139
|
+
warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
let files: EmittedFile[];
|
|
143
|
+
try {
|
|
144
|
+
files = await generator.generate(ctx);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
147
|
+
throw new Error(`[${generator.name}] ${msg}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const fullPath = join(selfTarget.outDir, file.path);
|
|
152
|
+
const collision = emitted.find((prev) => prev.fullPath === fullPath);
|
|
153
|
+
if (collision) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Output path collision: "${fullPath}" emitted by both ` +
|
|
156
|
+
`"${collision.generatedBy}" and "${generator.name}". ` +
|
|
157
|
+
`Adjust one generator's filter or output path.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
emitted.push({ fullPath, content: file.content, generatedBy: generator.name });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 5. Write phase.
|
|
165
|
+
const writes: WriteResult[] = [];
|
|
166
|
+
for (const file of emitted) {
|
|
167
|
+
const result = decideAndWrite(file.fullPath, file.content, strategy);
|
|
168
|
+
writes.push(result);
|
|
169
|
+
if (result.status === "refused") {
|
|
170
|
+
warnings.push(
|
|
171
|
+
`Refused to overwrite ${file.fullPath}: file exists without @generated header. ` +
|
|
172
|
+
`Move to a different outDir, delete the file, or add the header to opt in.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { files: writes, warnings };
|
|
178
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Barrel template — emits index.ts with one export per entity, alphabetical.
|
|
2
|
+
|
|
3
|
+
import { GENERATED_HEADER } from "../constants.js";
|
|
4
|
+
import { type ExtStyle } from "../render-context.js";
|
|
5
|
+
import { barrelModuleSpecifier, type ResolvedTarget } from "../import-path.js";
|
|
6
|
+
|
|
7
|
+
export interface BarrelEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
package: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function renderBarrel(
|
|
13
|
+
entries: BarrelEntry[],
|
|
14
|
+
extStyle: ExtStyle,
|
|
15
|
+
selfTarget: ResolvedTarget,
|
|
16
|
+
entityModuleTarget: ResolvedTarget,
|
|
17
|
+
): string {
|
|
18
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
19
|
+
const exports = sorted
|
|
20
|
+
.map((e) => `export * from ${JSON.stringify(barrelModuleSpecifier(selfTarget, entityModuleTarget, e.package, e.name, extStyle))};`)
|
|
21
|
+
.join("\n");
|
|
22
|
+
return `// ${GENERATED_HEADER} — DO NOT EDIT.\n${exports}\n`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Drizzle schema template — emits a single sqliteTable() or pgTable() definition,
|
|
2
|
+
// with FK .references() on FK columns derived from relationship children,
|
|
3
|
+
// plus the relations() block auto-emitted at the end.
|
|
4
|
+
|
|
5
|
+
import { code, imp, joinCode, type Code } from "ts-poet";
|
|
6
|
+
import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
|
|
7
|
+
import {
|
|
8
|
+
IDENTITY_SUBTYPE_SECONDARY, FIELD_SUBTYPE_LONG,
|
|
9
|
+
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_UNIQUE,
|
|
10
|
+
GENERATION_INCREMENT, GENERATION_UUID,
|
|
11
|
+
FIELD_ATTR_AUTO_SET,
|
|
12
|
+
} from "@metaobjectsdev/metadata";
|
|
13
|
+
import { type RenderContext } from "../render-context.js";
|
|
14
|
+
import { crossEntitySpecifier } from "../import-path.js";
|
|
15
|
+
import { mapColumnType } from "../column-mapper.js";
|
|
16
|
+
import { tableNameFromEntity, variableNameFromEntity, columnNameFromField } from "../naming.js";
|
|
17
|
+
import { renderRelationsBlock } from "./relations-block.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render the Drizzle table definition for one entity, including:
|
|
21
|
+
* - FK .references() on FK columns derived from relationship children
|
|
22
|
+
* - relations() block at the end of the section
|
|
23
|
+
*
|
|
24
|
+
* Returns a Code object so ts-poet can deduplicate imports when this composes
|
|
25
|
+
* with the rest of the entity file. Biome formatting runs after composition.
|
|
26
|
+
*/
|
|
27
|
+
export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
28
|
+
const dialect = ctx.dialect;
|
|
29
|
+
const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable";
|
|
30
|
+
const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
|
|
31
|
+
const tableFnSym = imp(`${tableFn}@${importModule}`);
|
|
32
|
+
|
|
33
|
+
const tableName = obj.dbTable ?? tableNameFromEntity(obj.name, ctx.columnNamingStrategy);
|
|
34
|
+
const varName = variableNameFromEntity(obj.name);
|
|
35
|
+
|
|
36
|
+
const primary = obj.primaryIdentity();
|
|
37
|
+
const rawPkFields = primary?.ownAttr(IDENTITY_ATTR_FIELDS);
|
|
38
|
+
const pkFieldsList: string[] = Array.isArray(rawPkFields)
|
|
39
|
+
? rawPkFields as string[]
|
|
40
|
+
: typeof rawPkFields === "string"
|
|
41
|
+
? rawPkFields.split(",").map((f) => f.trim()).filter(Boolean)
|
|
42
|
+
: [];
|
|
43
|
+
const pkFieldNames = new Set<string>(pkFieldsList);
|
|
44
|
+
const pkGeneration = primary?.ownAttr(IDENTITY_ATTR_GENERATION) as string | undefined;
|
|
45
|
+
|
|
46
|
+
const fkMap = buildFkMapForEntity(obj, ctx);
|
|
47
|
+
|
|
48
|
+
const isComposite = pkFieldNames.size > 1;
|
|
49
|
+
|
|
50
|
+
// Collect secondary identities and the field names that need .unique() on
|
|
51
|
+
// their column. Only single-column UNIQUE identities propagate .unique() to
|
|
52
|
+
// the column — non-unique indexes emit a separate index() callback entry
|
|
53
|
+
// (handled below) and must not stamp .unique() on the underlying column.
|
|
54
|
+
const secondaryIdentities = obj.secondaryIdentities();
|
|
55
|
+
const uniqueFieldNames = new Set<string>();
|
|
56
|
+
for (const sec of secondaryIdentities) {
|
|
57
|
+
const uniqueAttr = sec.ownAttr(IDENTITY_ATTR_UNIQUE);
|
|
58
|
+
if (uniqueAttr === false) continue; // explicit non-unique → don't mark column
|
|
59
|
+
const fields = sec.ownAttr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
|
|
60
|
+
if (!Array.isArray(fields) || fields.length !== 1) continue; // multi-col uniques use a callback index, not a column flag
|
|
61
|
+
uniqueFieldNames.add(fields[0]!);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const columnLines: Code[] = [];
|
|
65
|
+
for (const child of obj.fields()) {
|
|
66
|
+
const isPk = pkFieldNames.has(child.name);
|
|
67
|
+
const isUnique = uniqueFieldNames.has(child.name) && !isPk;
|
|
68
|
+
const fkInfo = fkMap.get(child.name);
|
|
69
|
+
columnLines.push(renderColumn(child, ctx, isPk, pkGeneration, fkInfo, isComposite, isUnique, obj.package));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build all table callback entries
|
|
73
|
+
const callbackEntries: Code[] = [];
|
|
74
|
+
|
|
75
|
+
if (isComposite && primary !== undefined) {
|
|
76
|
+
callbackEntries.push(buildCompositeKeyCallback(pkFieldNames, importModule));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const sec of secondaryIdentities) {
|
|
80
|
+
const fields = sec.ownAttr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
|
|
81
|
+
if (!Array.isArray(fields) || fields.length === 0) continue;
|
|
82
|
+
const indexName = `idx_${tableName}_${fields.map((f) => columnNameFromField(f, ctx.columnNamingStrategy)).join("_")}`;
|
|
83
|
+
// @unique on the identity defaults to true (preserves back-compat with
|
|
84
|
+
// foundations fixtures that assumed secondary identities were always
|
|
85
|
+
// unique). Explicit @unique: false → ordinary non-unique index.
|
|
86
|
+
const uniqueAttr = sec.ownAttr(IDENTITY_ATTR_UNIQUE);
|
|
87
|
+
const isUnique = uniqueAttr !== false;
|
|
88
|
+
const indexFn = isUnique ? "uniqueIndex" : "index";
|
|
89
|
+
const indexSym = imp(`${indexFn}@${importModule}`);
|
|
90
|
+
// Use the callback param `table` (not the outer varName) so TS doesn't see
|
|
91
|
+
// the table referencing itself inside its own initializer (TS7022/TS7024).
|
|
92
|
+
const cols = fields.map((f) => `table.${f}`).join(", ");
|
|
93
|
+
callbackEntries.push(code`${indexSym}(${JSON.stringify(indexName)}).on(${cols})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let tableBlock: Code;
|
|
97
|
+
if (callbackEntries.length > 0) {
|
|
98
|
+
tableBlock = code`
|
|
99
|
+
export const ${varName} = ${tableFnSym}(${JSON.stringify(tableName)}, {
|
|
100
|
+
${joinCode(columnLines, { on: ",\n", trim: false })}
|
|
101
|
+
}, (table) => [
|
|
102
|
+
${joinCode(callbackEntries, { on: ",\n ", trim: false })}
|
|
103
|
+
]);
|
|
104
|
+
`;
|
|
105
|
+
} else {
|
|
106
|
+
tableBlock = code`
|
|
107
|
+
export const ${varName} = ${tableFnSym}(${JSON.stringify(tableName)}, {
|
|
108
|
+
${joinCode(columnLines, { on: ",\n", trim: false })}
|
|
109
|
+
});
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Emit the relations() block (returns null if no relations).
|
|
114
|
+
const relationsBlock = renderRelationsBlock(obj, ctx);
|
|
115
|
+
|
|
116
|
+
if (relationsBlock === null) {
|
|
117
|
+
return tableBlock;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return joinCode([tableBlock, relationsBlock], { on: "\n" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface FkInfo {
|
|
124
|
+
targetVarName: string; // e.g., "users"
|
|
125
|
+
targetEntityName: string; // e.g., "User" — used for the import path
|
|
126
|
+
targetPkField: string; // e.g., "id"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */
|
|
130
|
+
function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, FkInfo> {
|
|
131
|
+
const result = new Map<string, FkInfo>();
|
|
132
|
+
for (const ref of obj.referenceIdentities()) {
|
|
133
|
+
// @enforce: false → logical-only reference. Skip the .references() emission;
|
|
134
|
+
// the column stays plain. Drizzle's relations() block (driven by
|
|
135
|
+
// relation-resolver) still includes the relationship for query navigation.
|
|
136
|
+
if (!ref.enforce) continue;
|
|
137
|
+
const fkFieldNames = ref.fields;
|
|
138
|
+
if (fkFieldNames.length === 0) continue;
|
|
139
|
+
const fkField = fkFieldNames[0]!;
|
|
140
|
+
const targetName = ref.targetEntity;
|
|
141
|
+
if (!targetName) continue;
|
|
142
|
+
const targetObj = ctx.loadedRoot.findObject(targetName);
|
|
143
|
+
if (!targetObj) continue;
|
|
144
|
+
const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id";
|
|
145
|
+
result.set(fkField, {
|
|
146
|
+
targetVarName: variableNameFromEntity(targetObj.name),
|
|
147
|
+
targetEntityName: targetObj.name,
|
|
148
|
+
targetPkField,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the `primaryKey({ columns: [table.f1, table.f2] })` Code expression
|
|
156
|
+
* for composite primary keys. Uses imp() so ts-poet tracks the import.
|
|
157
|
+
*/
|
|
158
|
+
function buildCompositeKeyCallback(
|
|
159
|
+
pkFieldNames: Set<string>,
|
|
160
|
+
importModule: string,
|
|
161
|
+
): Code {
|
|
162
|
+
const primaryKeySym = imp(`primaryKey@${importModule}`);
|
|
163
|
+
const columnRefs = Array.from(pkFieldNames)
|
|
164
|
+
.map((f) => `table.${f}`)
|
|
165
|
+
.join(", ");
|
|
166
|
+
return code`${primaryKeySym}({ columns: [${columnRefs}] })`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Build a JS-style object literal string (not JSON.stringify which uses quoted keys). */
|
|
170
|
+
function inlineObjectLiteral(obj: Record<string, unknown>): string {
|
|
171
|
+
const entries = Object.entries(obj).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
172
|
+
return `{ ${entries.join(", ")} }`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render one column line (field name + Drizzle column expression). */
|
|
176
|
+
function renderColumn(
|
|
177
|
+
field: MetaField,
|
|
178
|
+
ctx: RenderContext,
|
|
179
|
+
isPk: boolean,
|
|
180
|
+
pkGeneration: string | undefined,
|
|
181
|
+
fkInfo: FkInfo | undefined,
|
|
182
|
+
isComposite: boolean,
|
|
183
|
+
isUnique: boolean = false,
|
|
184
|
+
entityPackage: string | undefined = undefined,
|
|
185
|
+
): Code {
|
|
186
|
+
const spec = mapColumnType(field, ctx.dialect, ctx.columnNamingStrategy);
|
|
187
|
+
const fnSym = imp(`${spec.fnName}@${spec.importModule}`);
|
|
188
|
+
|
|
189
|
+
const dbNameLit = JSON.stringify(spec.dbName);
|
|
190
|
+
let baseCall: Code;
|
|
191
|
+
if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) {
|
|
192
|
+
baseCall = code`${fnSym}(${dbNameLit}, ${inlineObjectLiteral(spec.fnOptions)})`;
|
|
193
|
+
} else {
|
|
194
|
+
baseCall = code`${fnSym}(${dbNameLit})`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let pkSuffix = "";
|
|
198
|
+
if (isPk) {
|
|
199
|
+
if (pkGeneration === GENERATION_INCREMENT) {
|
|
200
|
+
if (ctx.dialect === "sqlite") {
|
|
201
|
+
// Composite PKs don't use .primaryKey() per-column; table callback owns it.
|
|
202
|
+
pkSuffix = isComposite ? "" : ".primaryKey({ autoIncrement: true })";
|
|
203
|
+
} else {
|
|
204
|
+
// Postgres: bigserial for long (8-byte), serial for int (4-byte).
|
|
205
|
+
if (field.subType === FIELD_SUBTYPE_LONG) {
|
|
206
|
+
const bigserialSym = imp(`bigserial@${spec.importModule}`);
|
|
207
|
+
baseCall = code`${bigserialSym}(${dbNameLit}, { mode: "number" })`;
|
|
208
|
+
} else {
|
|
209
|
+
const serialSym = imp(`serial@${spec.importModule}`);
|
|
210
|
+
baseCall = code`${serialSym}(${dbNameLit})`;
|
|
211
|
+
}
|
|
212
|
+
pkSuffix = isComposite ? "" : ".primaryKey()";
|
|
213
|
+
}
|
|
214
|
+
} else if (pkGeneration === GENERATION_UUID) {
|
|
215
|
+
pkSuffix = isComposite
|
|
216
|
+
? ""
|
|
217
|
+
: ctx.dialect === "sqlite"
|
|
218
|
+
? ".primaryKey().$defaultFn(() => crypto.randomUUID())"
|
|
219
|
+
: ".primaryKey().defaultRandom()";
|
|
220
|
+
} else {
|
|
221
|
+
// No generation: natural PK. Composite hands off to table callback.
|
|
222
|
+
pkSuffix = isComposite ? "" : ".primaryKey()";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let modifiersStr = pkSuffix;
|
|
227
|
+
// Append .unique() for secondary-identity fields (isPk guard already excluded PKs upstream).
|
|
228
|
+
if (isUnique) modifiersStr += ".unique()";
|
|
229
|
+
for (const m of spec.modifiers) {
|
|
230
|
+
// Single-column PKs imply notNull/unique; avoid emitting them twice.
|
|
231
|
+
// Composite-PK columns are NOT declared with .primaryKey(), so they DO need .notNull().
|
|
232
|
+
if (isPk && !isComposite && (m === ".notNull()" || m === ".unique()")) continue;
|
|
233
|
+
// Avoid double-emitting .unique() if it was already appended above.
|
|
234
|
+
if (isUnique && m === ".unique()") continue;
|
|
235
|
+
modifiersStr += m;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// sqlDefaultSegment must be a Code segment (not a raw string) so ts-poet tracks
|
|
239
|
+
// the `sql` import via imp(); a raw `.default(sql`...`)` would leave `sql`
|
|
240
|
+
// unresolved in the generated file.
|
|
241
|
+
let sqlDefaultSegment: Code | null = null;
|
|
242
|
+
if (spec.defaultExpr !== undefined && !isPk) {
|
|
243
|
+
if (spec.defaultExpr.kind === "now") {
|
|
244
|
+
if (ctx.dialect === "sqlite") {
|
|
245
|
+
const sqlSym = imp("sql@drizzle-orm");
|
|
246
|
+
sqlDefaultSegment = code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`;
|
|
247
|
+
} else {
|
|
248
|
+
modifiersStr += `.defaultNow()`;
|
|
249
|
+
}
|
|
250
|
+
} else if (spec.defaultExpr.kind === "sqlExpr") {
|
|
251
|
+
const sqlSym = imp("sql@drizzle-orm");
|
|
252
|
+
// Raw SQL keyword/expression — emit as sql`<raw>` for both dialects.
|
|
253
|
+
sqlDefaultSegment = code`.default(${sqlSym}\`${spec.defaultExpr.raw}\`)`;
|
|
254
|
+
} else {
|
|
255
|
+
// literal
|
|
256
|
+
modifiersStr += `.default(${JSON.stringify(spec.defaultExpr.value)})`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// FK .references() uses imp() so ts-poet tracks the cross-entity import.
|
|
261
|
+
let fkRefSegment: Code | null = null;
|
|
262
|
+
if (fkInfo !== undefined && !isPk) {
|
|
263
|
+
const targetSpec = crossEntitySpecifier(
|
|
264
|
+
ctx.outputLayout,
|
|
265
|
+
entityPackage,
|
|
266
|
+
ctx.packageOf.get(fkInfo.targetEntityName),
|
|
267
|
+
fkInfo.targetEntityName,
|
|
268
|
+
ctx.extStyle,
|
|
269
|
+
);
|
|
270
|
+
const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`);
|
|
271
|
+
fkRefSegment = code`.references(() => ${targetVarSym}.${fkInfo.targetPkField})`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// @autoSet fields: emit .$defaultFn(() => new Date().toISOString()) so Drizzle
|
|
275
|
+
// inserts stamp the server-side timestamp automatically. This means callers don't
|
|
276
|
+
// need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in.
|
|
277
|
+
const autoSet = field.ownAttr(FIELD_ATTR_AUTO_SET);
|
|
278
|
+
const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate")
|
|
279
|
+
? `.$defaultFn(() => new Date().toISOString())`
|
|
280
|
+
: "";
|
|
281
|
+
|
|
282
|
+
const columnLine = code` ${field.name}: ${baseCall}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`;
|
|
283
|
+
return spec.leadingComment !== undefined
|
|
284
|
+
? code` // ${spec.leadingComment}\n${columnLine}`
|
|
285
|
+
: columnLine;
|
|
286
|
+
}
|