@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.
- package/README.md +5 -3
- package/dist/hono/index.d.ts +44 -0
- package/dist/hono/index.d.ts.map +1 -0
- package/dist/hono/index.js +188 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/hono/mount-read-only.d.ts +18 -0
- package/dist/hono/mount-read-only.d.ts.map +1 -0
- package/dist/hono/mount-read-only.js +151 -0
- package/dist/hono/mount-read-only.js.map +1 -0
- package/dist/n2m-resolver.d.ts +4 -4
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +13 -13
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/object-manager.d.ts +8 -0
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +23 -19
- package/dist/object-manager.js.map +1 -1
- package/dist/query-builder.d.ts +7 -7
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +28 -26
- package/dist/query-builder.js.map +1 -1
- package/dist/relation-resolver.d.ts +3 -3
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +5 -5
- package/dist/relation-resolver.js.map +1 -1
- package/package.json +12 -2
- package/src/hono/index.ts +237 -0
- package/src/hono/mount-read-only.ts +189 -0
- package/src/n2m-resolver.ts +19 -11
- package/src/object-manager.ts +34 -19
- package/src/query-builder.ts +49 -25
- package/src/relation-resolver.ts +6 -3
|
@@ -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
|
+
}
|
package/src/n2m-resolver.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|