@metaobjectsdev/runtime-ts 0.6.0 → 0.7.0-rc.10

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.
@@ -0,0 +1,237 @@
1
+ // Drizzle-direct Hono adapter — Hono parallel of the drizzle-fastify mount
2
+ // helpers. Aimed at Workers / Bun / Node consumers running Hono on top of
3
+ // a Drizzle instance.
4
+ //
5
+ // Wire-format-identical to the Fastify flavor:
6
+ //
7
+ // GET {path} list with ?filter / ?sort / ?limit / ?offset / ?withCount=1
8
+ // GET {path}/:id findById, 404 if missing
9
+ // POST {path} create, 400 on Zod validation error, 201 on success
10
+ // PATCH {path}/:id update (default), 400 / 404 envelopes
11
+ // PUT {path}/:id update (when updateMethod === "put")
12
+ // DELETE {path}/:id delete, 204 on success, 404 if missing
13
+ //
14
+ // Filter / sort / withCount semantics are shared with drizzle-fastify via
15
+ // parseFilterParams and parseHonoFilterParams (qs.parse on the raw URL),
16
+ // so the two flavors emit byte-identical wire responses for the cross-port
17
+ // API-contract corpus.
18
+
19
+ import type { Hono, Context } from "hono";
20
+ import type { ZodTypeAny } from "zod";
21
+ import { eq, count, and } from "drizzle-orm";
22
+ import qs from "qs";
23
+ import {
24
+ parseFilterParams,
25
+ FilterParseError,
26
+ type ParseFilterResult,
27
+ } from "../drizzle-fastify/filter-parser.js";
28
+ import type {
29
+ FilterAllowlist,
30
+ SortAllowlist,
31
+ } from "../drizzle-fastify/filter-allowlist.js";
32
+ export type {
33
+ FilterAllowlist,
34
+ SortAllowlist,
35
+ } from "../drizzle-fastify/filter-allowlist.js";
36
+ import { isTruthyFlag } from "../drizzle-fastify/util.js";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Loose types — we don't bind to a specific Drizzle backend so the helper
40
+ // works across libsql / better-sqlite3 / D1 / pg / etc.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
44
+ type AnyDrizzle = any;
45
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
46
+ type AnyTable = any;
47
+ // biome-ignore lint/suspicious/noExplicitAny: generic Hono app — bindings/variables are the consumer's
48
+ type AnyHono = Hono<any, any, any>;
49
+
50
+ export type CrudVerb = "list" | "get" | "create" | "update" | "delete";
51
+
52
+ export interface CrudRoutesOptions {
53
+ /** Hono app instance. Bindings type is the consumer's — kept generic here. */
54
+ app: AnyHono;
55
+ /** REST resource path, e.g. "/subscribers". */
56
+ path: string;
57
+ /** User's Drizzle instance. */
58
+ db: AnyDrizzle;
59
+ /** Drizzle table const. Must expose an `id` column. */
60
+ table: AnyTable;
61
+ /** Zod schema for create payloads (typically `<Entity>InsertSchema`). */
62
+ insertSchema: ZodTypeAny;
63
+ /** Zod schema for update payloads (typically `<Entity>UpdateSchema`). */
64
+ updateSchema: ZodTypeAny;
65
+ /** Limit which verbs are mounted. Defaults to all five. */
66
+ expose?: readonly CrudVerb[];
67
+ /**
68
+ * HTTP method for the update verb. Defaults to "patch". Set to "put" to
69
+ * preserve a legacy API contract that already uses PUT for updates.
70
+ */
71
+ updateMethod?: "patch" | "put";
72
+ filterAllowlist?: FilterAllowlist;
73
+ sortAllowlist?: SortAllowlist;
74
+ /** Dialect — required if filterAllowlist or sortAllowlist is set (for like/ilike dispatch). */
75
+ dialect?: "sqlite" | "postgres";
76
+ }
77
+
78
+ const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
79
+
80
+ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
81
+ const verbs = new Set<CrudVerb>(opts.expose ?? ALL_VERBS);
82
+ if (verbs.has("list")) mountListRoute(opts);
83
+ if (verbs.has("get")) mountGetRoute(opts);
84
+ if (verbs.has("create")) mountCreateRoute(opts);
85
+ if (verbs.has("update")) mountUpdateRoute(opts);
86
+ if (verbs.has("delete")) mountDeleteRoute(opts);
87
+ }
88
+
89
+ type VerbOptions = Omit<CrudRoutesOptions, "expose">;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Hono-flavored qs.parse — analogue of Fastify's qs.parse(req.raw.url ...).
93
+ //
94
+ // Hono exposes the raw URL via c.req.url; we strip the prefix and feed the
95
+ // raw query string to qs so the bracketed filter notation (filter[a][eq]=...)
96
+ // is recognised. The plain c.req.query() helper drops bracket structure, so
97
+ // it can't replace this.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export function parseHonoFilterParams(c: Context): Record<string, unknown> {
101
+ const url = c.req.url;
102
+ const qIdx = url.indexOf("?");
103
+ const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
104
+ return qs.parse(queryString) as Record<string, unknown>;
105
+ }
106
+
107
+ export function mountListRoute(opts: VerbOptions): void {
108
+ opts.app.get(opts.path, async (c) => {
109
+ try {
110
+ let q = opts.db.select().from(opts.table).$dynamic();
111
+ const qsParsed = parseHonoFilterParams(c);
112
+ const withCount = isTruthyFlag(qsParsed.withCount);
113
+
114
+ let where: ParseFilterResult["where"];
115
+ if (opts.filterAllowlist && opts.sortAllowlist) {
116
+ const parsed = parseFilterParams({
117
+ query: qsParsed,
118
+ table: opts.table,
119
+ allowlist: opts.filterAllowlist,
120
+ sortAllowlist: opts.sortAllowlist,
121
+ dialect: opts.dialect ?? "sqlite",
122
+ });
123
+ const combinedWhere = parsed.where && parsed.searchWhere
124
+ ? and(parsed.where, parsed.searchWhere)
125
+ : (parsed.where ?? parsed.searchWhere);
126
+ if (combinedWhere) { q = q.where(combinedWhere); where = combinedWhere; }
127
+ if (parsed.orderBy) q = q.orderBy(...parsed.orderBy);
128
+ if (parsed.limit !== undefined) q = q.limit(parsed.limit);
129
+ if (parsed.offset !== undefined) q = q.offset(parsed.offset);
130
+ } else {
131
+ // No allowlists configured. Only limit/offset.
132
+ const flat = c.req.query();
133
+ if (flat.limit !== undefined) q = q.limit(Number(flat.limit));
134
+ if (flat.offset !== undefined) q = q.offset(Number(flat.offset));
135
+ }
136
+
137
+ const rows = await q.all();
138
+
139
+ if (!withCount) return c.json(rows);
140
+
141
+ // Count query: same WHERE, no limit/offset/orderBy.
142
+ let cq = opts.db.select({ c: count() }).from(opts.table).$dynamic();
143
+ if (where) cq = cq.where(where);
144
+ const countRow = (await cq.all())[0] as { c: number } | undefined;
145
+ const total = countRow?.c ?? 0;
146
+ return c.json({ rows, total });
147
+ } catch (err) {
148
+ if (err instanceof FilterParseError) {
149
+ return c.json({ error: err.code, ...(err.details ?? {}) }, 400);
150
+ }
151
+ throw err;
152
+ }
153
+ });
154
+ }
155
+
156
+ export function mountGetRoute(opts: VerbOptions): void {
157
+ opts.app.get(`${opts.path}/:id`, async (c) => {
158
+ const id = c.req.param("id") ?? "";
159
+ const row = await opts.db
160
+ .select()
161
+ .from(opts.table)
162
+ .where(eq(opts.table.id, parseId(id)))
163
+ .get();
164
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
165
+ });
166
+ }
167
+
168
+ export function mountCreateRoute(opts: VerbOptions): void {
169
+ opts.app.post(opts.path, async (c) => {
170
+ const body = await c.req.json().catch(() => undefined);
171
+ const parsed = opts.insertSchema.safeParse(body);
172
+ if (!parsed.success) {
173
+ return c.json({ error: "validation", issues: parsed.error.issues }, 400);
174
+ }
175
+ const result = await opts.db.insert(opts.table).values(parsed.data).returning();
176
+ const row = (result as unknown[])[0];
177
+ return c.json(row, 201);
178
+ });
179
+ }
180
+
181
+ export function mountUpdateRoute(opts: VerbOptions): void {
182
+ const handler = async (c: Context) => {
183
+ const id = c.req.param("id") ?? "";
184
+ const body = await c.req.json().catch(() => undefined);
185
+ const parsed = opts.updateSchema.safeParse(body);
186
+ if (!parsed.success) {
187
+ return c.json({ error: "validation", issues: parsed.error.issues }, 400);
188
+ }
189
+ const result = await opts.db
190
+ .update(opts.table)
191
+ .set(parsed.data)
192
+ .where(eq(opts.table.id, parseId(id)))
193
+ .returning();
194
+ const row = (result as unknown[])[0];
195
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
196
+ };
197
+ const path = `${opts.path}/:id`;
198
+ if (opts.updateMethod === "put") {
199
+ opts.app.put(path, handler);
200
+ } else {
201
+ opts.app.patch(path, handler);
202
+ }
203
+ }
204
+
205
+ export function mountDeleteRoute(opts: VerbOptions): void {
206
+ opts.app.delete(`${opts.path}/:id`, async (c) => {
207
+ const id = c.req.param("id") ?? "";
208
+ const result = await opts.db
209
+ .delete(opts.table)
210
+ .where(eq(opts.table.id, parseId(id)));
211
+ const affected = extractRowCount(result);
212
+ if (affected > 0) {
213
+ // 204 No Content — body must be empty.
214
+ return c.body(null, 204);
215
+ }
216
+ return c.json({ error: "not_found" }, 404);
217
+ });
218
+ }
219
+
220
+ function extractRowCount(result: unknown): number {
221
+ if (typeof result === "number") return result;
222
+ if (Array.isArray(result)) return result.length;
223
+ if (result && typeof result === "object") {
224
+ const obj = result as { rowsAffected?: number | bigint; rowCount?: number };
225
+ if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
226
+ if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
227
+ if (typeof obj.rowCount === "number") return obj.rowCount;
228
+ }
229
+ return 0;
230
+ }
231
+
232
+ export function parseId(raw: string): number | string {
233
+ const n = Number(raw);
234
+ return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
235
+ }
236
+
237
+ export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
@@ -0,0 +1,189 @@
1
+ // Hono read-only mount — projection (view-backed) entities. GET list +
2
+ // GET :id only. POST/PATCH/DELETE return 405. Mirrors the drizzle-fastify
3
+ // equivalent so the cross-port API contract holds for projection endpoints.
4
+
5
+ import type { Hono } from "hono";
6
+ import { sql, eq, and, count } from "drizzle-orm";
7
+ import qs from "qs";
8
+ import { parseFilterParams, FilterParseError } from "../drizzle-fastify/filter-parser.js";
9
+ import type {
10
+ FilterAllowlist,
11
+ SortAllowlist,
12
+ } from "../drizzle-fastify/filter-allowlist.js";
13
+ import { isTruthyFlag } from "../drizzle-fastify/util.js";
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
16
+ type AnyView = any;
17
+ // biome-ignore lint/suspicious/noExplicitAny: generic Hono app
18
+ type AnyHono = Hono<any, any, any>;
19
+
20
+ /**
21
+ * Drizzle v0.45 stores view config under this well-known Symbol. Mirrors
22
+ * the same workaround the Fastify mount uses — accessing `view._` on a
23
+ * proxy-wrapped view throws.
24
+ */
25
+ const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
26
+
27
+ export interface MountReadOnlyOptions {
28
+ readonly app: AnyHono;
29
+ readonly path: string;
30
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic Drizzle client
31
+ readonly db: any;
32
+ readonly view: AnyView;
33
+ readonly filterAllowlist: FilterAllowlist;
34
+ readonly sortAllowlist: SortAllowlist;
35
+ readonly dialect: "postgres" | "sqlite";
36
+ /** Override default ID column name (defaults to "id"). */
37
+ readonly idColumn?: string;
38
+ }
39
+
40
+ function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
41
+ try {
42
+ const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
43
+ if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
44
+ } catch {
45
+ // ignore — proxy handler may throw on unexpected shapes
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function resolveViewName(view: AnyView): string | undefined {
51
+ const cfg = getViewConfig(view);
52
+ if (cfg) {
53
+ if (typeof cfg["name"] === "string") return cfg["name"] as string;
54
+ }
55
+ const v = view as Record<string, unknown>;
56
+ return (
57
+ (typeof v["__tableName"] === "string" ? v["__tableName"] as string : undefined) ??
58
+ (typeof v["_name"] === "string" ? v["_name"] as string : undefined)
59
+ );
60
+ }
61
+
62
+ function isEmptyColumnView(view: AnyView): boolean {
63
+ const cfg = getViewConfig(view);
64
+ if (cfg) {
65
+ const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
66
+ return fields !== undefined && Object.keys(fields).length === 0;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function snakeToCamel(s: string): string {
72
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
73
+ }
74
+
75
+ function camelizeRow(row: Record<string, unknown>): Record<string, unknown> {
76
+ const out: Record<string, unknown> = {};
77
+ for (const [k, v] of Object.entries(row)) {
78
+ out[snakeToCamel(k)] = v;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
84
+ const { app, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
85
+ const idCol = opts.idColumn ?? "id";
86
+
87
+ const viewName = resolveViewName(view);
88
+ const useRawSql = isEmptyColumnView(view) && !!viewName;
89
+
90
+ // ── List ──────────────────────────────────────────────────────────────────
91
+ app.get(path, async (c) => {
92
+ try {
93
+ if (useRawSql) {
94
+ const url = c.req.url;
95
+ const qIdx = url.indexOf("?");
96
+ const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
97
+ const parsed = qs.parse(queryString) as Record<string, unknown>;
98
+ const limitVal = Math.min(
99
+ 1000,
100
+ Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)),
101
+ );
102
+ const offsetVal = Math.max(
103
+ 0,
104
+ Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0),
105
+ );
106
+ const withCount = isTruthyFlag(parsed["withCount"]);
107
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
108
+ const rows = (await db.all(sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
109
+ const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
110
+ if (!withCount) return c.json(camelRows);
111
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
112
+ const countRows = (await db.all(sql.raw(`SELECT COUNT(*) AS c FROM "${viewName}"`))) as any[];
113
+ const total: number = Number(countRows[0]?.c ?? 0);
114
+ return c.json({ rows: camelRows, total });
115
+ }
116
+
117
+ const url = c.req.url;
118
+ const qIdx = url.indexOf("?");
119
+ const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
120
+ const parsed = qs.parse(queryString) as Record<string, unknown>;
121
+ const withCount = isTruthyFlag(parsed["withCount"]);
122
+ const result = parseFilterParams({
123
+ query: parsed,
124
+ table: view,
125
+ allowlist: filterAllowlist,
126
+ sortAllowlist,
127
+ dialect,
128
+ });
129
+ const combinedWhere = result.where && result.searchWhere
130
+ ? and(result.where, result.searchWhere)
131
+ : (result.where ?? result.searchWhere);
132
+ let q = db.select().from(view);
133
+ if (combinedWhere) q = q.where(combinedWhere);
134
+ if (result.orderBy) q = q.orderBy(...result.orderBy);
135
+ if (result.limit !== undefined) q = q.limit(result.limit);
136
+ if (result.offset !== undefined) q = q.offset(result.offset);
137
+ const rows = await q.all();
138
+
139
+ if (!withCount) return c.json(rows);
140
+
141
+ let cq = db.select({ c: count() }).from(view);
142
+ if (combinedWhere) cq = cq.where(combinedWhere);
143
+ const countRow = (await cq.all())[0] as { c: number } | undefined;
144
+ const total = countRow?.c ?? 0;
145
+ return c.json({ rows, total });
146
+ } catch (err) {
147
+ if (err instanceof FilterParseError) {
148
+ return c.json({ error: err.code, message: err.message }, 400);
149
+ }
150
+ throw err;
151
+ }
152
+ });
153
+
154
+ // ── Get by ID ─────────────────────────────────────────────────────────────
155
+ app.get(`${path}/:id`, async (c) => {
156
+ const id = c.req.param("id") ?? "";
157
+ if (useRawSql) {
158
+ const numericId = Number(id);
159
+ if (!Number.isFinite(numericId)) {
160
+ return c.json({ error: "invalid_id" }, 400);
161
+ }
162
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
163
+ const rows = (await db.all(sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${numericId} LIMIT 1`))) as any[];
164
+ const row = rows[0] ? camelizeRow(rows[0]) : undefined;
165
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
166
+ }
167
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
168
+ const colRef = (view as any)[idCol];
169
+ const row = await db
170
+ .select()
171
+ .from(view)
172
+ .where(colRef !== undefined ? eq(colRef, Number(id)) : undefined)
173
+ .get();
174
+ return row ? c.json(row) : c.json({ error: "not_found" }, 404);
175
+ });
176
+
177
+ // ── Mutations explicitly rejected (405) ───────────────────────────────────
178
+ const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
179
+ c.json(
180
+ { error: "method_not_allowed", message: `${c.req.method} is not supported on a projection (read-only).` },
181
+ 405,
182
+ );
183
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
184
+ app.post(path, reject as any);
185
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
186
+ app.patch(`${path}/:id`, reject as any);
187
+ // biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
188
+ app.delete(`${path}/:id`, reject as any);
189
+ }
@@ -1,12 +1,13 @@
1
1
  // Two-stage N:M: first query the join entity for FK pairs, then query the target entity for the rows.
2
2
  // The relationship declares @joinEntity + @joinFields: [sourceJoinField, targetJoinField].
3
3
 
4
- import type { MetaData } from "@metaobjectsdev/metadata";
4
+ import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
5
5
  import {
6
6
  TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
7
7
  RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF,
8
8
  RELATIONSHIP_ATTR_JOIN_ENTITY, RELATIONSHIP_ATTR_JOIN_FIELDS,
9
9
  CARDINALITY_MANY,
10
+ DEFAULT_COLUMN_NAMING_STRATEGY,
10
11
  resolveColumnName,
11
12
  } from "@metaobjectsdev/metadata";
12
13
  import { MetadataError } from "./errors.js";
@@ -77,19 +78,20 @@ export function buildN2mLazySpecs(
77
78
  desc: N2mDescriptor,
78
79
  sourceRecord: Row,
79
80
  root: MetaData,
81
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
80
82
  ): N2mLazyOutput {
81
83
  const joinEntity = mustGetEntity(root, desc.joinEntityName);
82
84
  const targetEntity = mustGetEntity(root, desc.targetEntityName);
83
85
  const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
84
86
  const sourcePkValue = sourceRecord[sourcePkField];
85
87
 
86
- const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourcePkValue as PrimitiveValue }, {});
88
+ const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourcePkValue as PrimitiveValue }, {}, undefined, strategy);
87
89
 
88
90
  const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
89
- const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity);
91
+ const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity, strategy);
90
92
  if (targetIds.length === 0) return null;
91
93
  const targetPkField = resolvePkFields(targetEntity)[0]!;
92
- return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {});
94
+ return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {}, undefined, strategy);
93
95
  };
94
96
 
95
97
  return { joinSpec, makeTargetSpec };
@@ -99,19 +101,20 @@ export function buildN2mBatchSpecs(
99
101
  desc: N2mDescriptor,
100
102
  sourceRecords: Row[],
101
103
  root: MetaData,
104
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
102
105
  ): N2mBatchOutput {
103
106
  const joinEntity = mustGetEntity(root, desc.joinEntityName);
104
107
  const targetEntity = mustGetEntity(root, desc.targetEntityName);
105
108
  const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
106
109
  const sourceIds = collectIds(sourceRecords, sourcePkField);
107
110
 
108
- const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourceIds }, {});
111
+ const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourceIds }, {}, undefined, strategy);
109
112
 
110
113
  const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
111
- const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity);
114
+ const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity, strategy);
112
115
  if (targetIds.length === 0) return null;
113
116
  const targetPkField = resolvePkFields(targetEntity)[0]!;
114
- return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {});
117
+ return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {}, undefined, strategy);
115
118
  };
116
119
 
117
120
  return { joinSpec, makeTargetSpec };
@@ -133,9 +136,11 @@ function collectIds(records: Row[], pkField: string): (string | number)[] {
133
136
  return [...seen] as (string | number)[];
134
137
  }
135
138
 
136
- function collectTargetIds(joinRows: Row[], targetJoinField: string, joinEntity: MetaData): (string | number)[] {
139
+ function collectTargetIds(
140
+ joinRows: Row[], targetJoinField: string, joinEntity: MetaData, strategy: ColumnNamingStrategy,
141
+ ): (string | number)[] {
137
142
  // joinRows are raw column-keyed (driver hasn't been to-JS-row'd yet); resolve the metadata field name to its DB column.
138
- const dbColumn = resolveJoinColumnName(joinEntity, targetJoinField);
143
+ const dbColumn = resolveJoinColumnName(joinEntity, targetJoinField, strategy);
139
144
  const seen = new Set<PrimitiveValue>();
140
145
  for (const r of joinRows) {
141
146
  const v = r[dbColumn];
@@ -145,8 +150,11 @@ function collectTargetIds(joinRows: Row[], targetJoinField: string, joinEntity:
145
150
  return [...seen] as (string | number)[];
146
151
  }
147
152
 
148
- export function resolveJoinColumnName(joinEntity: MetaData, fieldName: string): string {
153
+ export function resolveJoinColumnName(
154
+ joinEntity: MetaData, fieldName: string,
155
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
156
+ ): string {
149
157
  const field = joinEntity.ownChildren().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
150
158
  if (!field) throw new MetadataError(`Join field '${fieldName}' not on '${joinEntity.name}'`);
151
- return resolveColumnName(field);
159
+ return resolveColumnName(field, strategy);
152
160
  }