@promakeai/orm 1.0.0

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,322 @@
1
+ /**
2
+ * Populate Resolver
3
+ *
4
+ * Resolves references (foreign keys) in query results using batch fetching
5
+ * to avoid N+1 query problems.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const products = await orm.list('products', {
10
+ * populate: ['categoryId', 'brandId']
11
+ * });
12
+ * // Each product now has .category and .brand objects
13
+ *
14
+ * // Nested populate with options
15
+ * const orders = await orm.list('orders', {
16
+ * populate: {
17
+ * userId: true,
18
+ * items: {
19
+ * populate: { productId: true },
20
+ * limit: 10
21
+ * }
22
+ * }
23
+ * });
24
+ * ```
25
+ */
26
+
27
+ import type {
28
+ SchemaDefinition,
29
+ TableDefinition,
30
+ FieldDefinition,
31
+ PopulateOption,
32
+ PopulateNested,
33
+ } from "../types";
34
+
35
+ /**
36
+ * Adapter interface for fetching records
37
+ * Minimal interface needed by populate resolver
38
+ */
39
+ export interface PopulateAdapter {
40
+ findMany<T = unknown>(
41
+ table: string,
42
+ options?: { where?: Record<string, unknown> }
43
+ ): Promise<T[]>;
44
+ }
45
+
46
+ /**
47
+ * Resolved reference info
48
+ */
49
+ interface ResolvedRef {
50
+ table: string;
51
+ field: string;
52
+ localField: string;
53
+ targetField: string;
54
+ isArray: boolean;
55
+ }
56
+
57
+ /**
58
+ * Normalized populate entry
59
+ */
60
+ interface NormalizedPopulateEntry {
61
+ fieldName: string;
62
+ options?: PopulateNested;
63
+ }
64
+
65
+ /**
66
+ * Get reference info from field definition
67
+ */
68
+ function getRefInfo(
69
+ fieldName: string,
70
+ field: FieldDefinition
71
+ ): ResolvedRef | null {
72
+ if (!field.ref) return null;
73
+
74
+ const ref = field.ref;
75
+ const isString = typeof ref === "string";
76
+
77
+ return {
78
+ table: isString ? ref : ref.table,
79
+ field: isString ? "id" : ref.field || "id",
80
+ localField: fieldName,
81
+ targetField: isString ? "id" : ref.field || "id",
82
+ isArray: Array.isArray(field.default) || fieldName.endsWith("Ids"),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Convert field name to populated property name
88
+ * categoryId -> category
89
+ * tagIds -> tags
90
+ * user_id -> user
91
+ */
92
+ function toPopulatedName(fieldName: string): string {
93
+ // Handle array refs: tagIds -> tags
94
+ if (fieldName.endsWith("Ids")) {
95
+ return fieldName.slice(0, -3) + "s";
96
+ }
97
+ // Handle singular refs: categoryId -> category, user_id -> user
98
+ if (fieldName.endsWith("Id")) {
99
+ return fieldName.slice(0, -2);
100
+ }
101
+ if (fieldName.endsWith("_id")) {
102
+ return fieldName.slice(0, -3);
103
+ }
104
+ return fieldName;
105
+ }
106
+
107
+ /**
108
+ * Extract unique values from records for a given field
109
+ */
110
+ function extractUniqueValues<T>(
111
+ records: T[],
112
+ fieldName: string
113
+ ): (string | number)[] {
114
+ const values = new Set<string | number>();
115
+
116
+ for (const record of records) {
117
+ const value = (record as Record<string, unknown>)[fieldName];
118
+ if (value == null) continue;
119
+
120
+ if (Array.isArray(value)) {
121
+ for (const v of value) {
122
+ if (v != null) values.add(v as string | number);
123
+ }
124
+ } else {
125
+ values.add(value as string | number);
126
+ }
127
+ }
128
+
129
+ return Array.from(values);
130
+ }
131
+
132
+ /**
133
+ * Create lookup map from records by field
134
+ */
135
+ function createLookupMap<T>(
136
+ records: T[],
137
+ keyField: string
138
+ ): Map<string | number, T> {
139
+ const map = new Map<string | number, T>();
140
+ for (const record of records) {
141
+ const key = (record as Record<string, unknown>)[keyField];
142
+ if (key != null) {
143
+ map.set(key as string | number, record);
144
+ }
145
+ }
146
+ return map;
147
+ }
148
+
149
+ /**
150
+ * Normalize populate option to array of entries
151
+ */
152
+ function normalizePopulate(
153
+ populate: PopulateOption | undefined
154
+ ): NormalizedPopulateEntry[] {
155
+ if (!populate) return [];
156
+
157
+ // String: "userId categoryIds" -> ["userId", "categoryIds"]
158
+ if (typeof populate === "string") {
159
+ return populate.split(/\s+/).map((fieldName) => ({ fieldName }));
160
+ }
161
+
162
+ // Array: ["userId", "categoryIds"]
163
+ if (Array.isArray(populate)) {
164
+ return populate.map((fieldName) => ({ fieldName }));
165
+ }
166
+
167
+ // Object: { userId: true, categoryIds: { populate: ... } }
168
+ const entries: NormalizedPopulateEntry[] = [];
169
+ for (const [fieldName, value] of Object.entries(populate)) {
170
+ if (value === true) {
171
+ entries.push({ fieldName });
172
+ } else if (value && typeof value === "object") {
173
+ entries.push({ fieldName, options: value as PopulateNested });
174
+ }
175
+ }
176
+ return entries;
177
+ }
178
+
179
+ /**
180
+ * Resolve populated references for query results
181
+ *
182
+ * @param records - Query results to populate
183
+ * @param table - Table name of the records
184
+ * @param populate - Fields to populate
185
+ * @param schema - Schema definition
186
+ * @param adapter - Data adapter for fetching
187
+ * @returns Records with populated references
188
+ */
189
+ export async function resolvePopulate<T>(
190
+ records: T[],
191
+ table: string,
192
+ populate: PopulateOption | undefined,
193
+ schema: SchemaDefinition,
194
+ adapter: PopulateAdapter
195
+ ): Promise<T[]> {
196
+ if (!records.length) return records;
197
+
198
+ const populateEntries = normalizePopulate(populate);
199
+ if (!populateEntries.length) return records;
200
+
201
+ const tableDef = schema.tables[table];
202
+ if (!tableDef) return records;
203
+
204
+ // Process each populate entry
205
+ for (const entry of populateEntries) {
206
+ const { fieldName, options } = entry;
207
+
208
+ const field = tableDef.fields[fieldName];
209
+ if (!field) continue;
210
+
211
+ const refInfo = getRefInfo(fieldName, field);
212
+ if (!refInfo) continue;
213
+
214
+ // Extract unique foreign key values
215
+ const fkValues = extractUniqueValues(records, fieldName);
216
+ if (!fkValues.length) continue;
217
+
218
+ // Batch fetch referenced records
219
+ const refRecords = await adapter.findMany(refInfo.table, {
220
+ where: { [refInfo.targetField]: { $in: fkValues } },
221
+ });
222
+
223
+ // Recursively populate nested references
224
+ let populatedRefRecords = refRecords;
225
+ if (options?.populate) {
226
+ populatedRefRecords = await resolvePopulate(
227
+ refRecords,
228
+ refInfo.table,
229
+ options.populate,
230
+ schema,
231
+ adapter
232
+ );
233
+ }
234
+
235
+ // Create lookup map
236
+ const lookupMap = createLookupMap(populatedRefRecords, refInfo.targetField);
237
+
238
+ // Attach populated data to records
239
+ const populatedName = toPopulatedName(fieldName);
240
+
241
+ for (const record of records) {
242
+ const fkValue = (record as Record<string, unknown>)[fieldName];
243
+
244
+ if (fkValue == null) {
245
+ (record as Record<string, unknown>)[populatedName] = null;
246
+ continue;
247
+ }
248
+
249
+ if (refInfo.isArray && Array.isArray(fkValue)) {
250
+ // Array reference: tagIds -> tags[]
251
+ (record as Record<string, unknown>)[populatedName] = fkValue
252
+ .map((v: unknown) => lookupMap.get(v as string | number))
253
+ .filter(Boolean);
254
+ } else {
255
+ // Single reference: categoryId -> category
256
+ (record as Record<string, unknown>)[populatedName] =
257
+ lookupMap.get(fkValue as string | number) || null;
258
+ }
259
+ }
260
+ }
261
+
262
+ return records;
263
+ }
264
+
265
+ /**
266
+ * Get all populatable fields from a table
267
+ */
268
+ export function getPopulatableFields(tableDef: TableDefinition): string[] {
269
+ return Object.entries(tableDef.fields)
270
+ .filter(([_, field]) => field.ref != null)
271
+ .map(([name]) => name);
272
+ }
273
+
274
+ /**
275
+ * Validate populate options against schema
276
+ */
277
+ export function validatePopulate(
278
+ populate: PopulateOption | undefined,
279
+ table: string,
280
+ schema: SchemaDefinition
281
+ ): { valid: boolean; errors: string[] } {
282
+ const errors: string[] = [];
283
+ const populateEntries = normalizePopulate(populate);
284
+
285
+ const tableDef = schema.tables[table];
286
+ if (!tableDef) {
287
+ errors.push(`Table '${table}' not found in schema`);
288
+ return { valid: false, errors };
289
+ }
290
+
291
+ for (const entry of populateEntries) {
292
+ const { fieldName, options } = entry;
293
+ const field = tableDef.fields[fieldName];
294
+
295
+ if (!field) {
296
+ errors.push(`Field '${fieldName}' not found in table '${table}'`);
297
+ continue;
298
+ }
299
+
300
+ if (!field.ref) {
301
+ errors.push(
302
+ `Field '${fieldName}' in table '${table}' is not a reference field`
303
+ );
304
+ continue;
305
+ }
306
+
307
+ // Validate nested populate
308
+ if (options?.populate) {
309
+ const refInfo = getRefInfo(fieldName, field);
310
+ if (refInfo) {
311
+ const nestedValidation = validatePopulate(
312
+ options.populate,
313
+ refInfo.table,
314
+ schema
315
+ );
316
+ errors.push(...nestedValidation.errors);
317
+ }
318
+ }
319
+ }
320
+
321
+ return { valid: errors.length === 0, errors };
322
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Translation Query Builder
3
+ *
4
+ * Build SQL queries with automatic translation table joins
5
+ * and COALESCE for language fallback.
6
+ */
7
+
8
+ import type { SchemaDefinition, TableDefinition, OrderByOption } from "../types";
9
+ import {
10
+ getTranslatableFields,
11
+ hasTranslatableFields,
12
+ } from "../schema/schemaHelpers";
13
+ import {
14
+ singularize,
15
+ toTranslationTableName,
16
+ toTranslationFKName,
17
+ } from "../schema/helpers";
18
+ import { buildWhereClause, type WhereResult } from "./whereBuilder";
19
+
20
+ /**
21
+ * Build WHERE clause with optional table alias prefix
22
+ */
23
+ function buildAliasedWhereClause(
24
+ where: Record<string, unknown>,
25
+ alias?: string
26
+ ): WhereResult {
27
+ const result = buildWhereClause(where);
28
+
29
+ if (!alias || !result.sql) {
30
+ return result;
31
+ }
32
+
33
+ let sql = result.sql;
34
+ const fields = Object.keys(where).filter((k) => !k.startsWith("$"));
35
+
36
+ for (const field of fields) {
37
+ const regex = new RegExp(`\\b${field}\\b(?!\\.)`, "g");
38
+ sql = sql.replace(regex, `${alias}.${field}`);
39
+ }
40
+
41
+ return { sql, params: result.params };
42
+ }
43
+
44
+ /**
45
+ * Options for translation queries
46
+ */
47
+ export interface TranslationQueryOptions {
48
+ table: string;
49
+ schema: SchemaDefinition;
50
+ lang: string;
51
+ fallbackLang?: string;
52
+ where?: Record<string, unknown>;
53
+ orderBy?: OrderByOption[];
54
+ limit?: number;
55
+ offset?: number;
56
+ }
57
+
58
+ /**
59
+ * Query result with SQL and parameters
60
+ */
61
+ export interface TranslationQueryResult {
62
+ sql: string;
63
+ params: unknown[];
64
+ }
65
+
66
+ /**
67
+ * Build SELECT query with translation joins
68
+ */
69
+ export function buildTranslationQuery(
70
+ options: TranslationQueryOptions
71
+ ): TranslationQueryResult {
72
+ const {
73
+ table,
74
+ schema,
75
+ lang,
76
+ fallbackLang = schema.languages.default,
77
+ where,
78
+ orderBy,
79
+ limit,
80
+ offset,
81
+ } = options;
82
+
83
+ const tableSchema = schema.tables[table];
84
+ if (!tableSchema) {
85
+ throw new Error(`Table not found in schema: ${table}`);
86
+ }
87
+
88
+ const translatableFields = getTranslatableFields(tableSchema);
89
+
90
+ if (translatableFields.length === 0) {
91
+ return buildSimpleQuery(table, { where, orderBy, limit, offset });
92
+ }
93
+
94
+ const transTable = toTranslationTableName(table);
95
+ const fkName = toTranslationFKName(table);
96
+
97
+ const mainAlias = "m";
98
+ const transAlias = "t";
99
+ const fallbackAlias = "fb";
100
+
101
+ // Build SELECT fields
102
+ const selectFields: string[] = [];
103
+
104
+ for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
105
+ if (field.translatable) {
106
+ selectFields.push(
107
+ `COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`
108
+ );
109
+ } else {
110
+ selectFields.push(`${mainAlias}.${fieldName}`);
111
+ }
112
+ }
113
+
114
+ let sql = `SELECT ${selectFields.join(", ")}
115
+ FROM ${table} ${mainAlias}
116
+ LEFT JOIN ${transTable} ${transAlias}
117
+ ON ${mainAlias}.id = ${transAlias}.${fkName}
118
+ AND ${transAlias}.language_code = ?
119
+ LEFT JOIN ${transTable} ${fallbackAlias}
120
+ ON ${mainAlias}.id = ${fallbackAlias}.${fkName}
121
+ AND ${fallbackAlias}.language_code = ?`;
122
+
123
+ const params: unknown[] = [lang, fallbackLang];
124
+
125
+ if (where && Object.keys(where).length > 0) {
126
+ const whereResult = buildAliasedWhereClause(where, mainAlias);
127
+ sql += `\nWHERE ${whereResult.sql}`;
128
+ params.push(...whereResult.params);
129
+ }
130
+
131
+ if (orderBy && orderBy.length > 0) {
132
+ const orderClauses = orderBy.map((o) => {
133
+ if (translatableFields.includes(o.field)) {
134
+ return `COALESCE(${transAlias}.${o.field}, ${fallbackAlias}.${o.field}) ${o.direction}`;
135
+ }
136
+ return `${mainAlias}.${o.field} ${o.direction}`;
137
+ });
138
+ sql += `\nORDER BY ${orderClauses.join(", ")}`;
139
+ }
140
+
141
+ if (limit !== undefined) {
142
+ sql += `\nLIMIT ?`;
143
+ params.push(limit);
144
+ }
145
+
146
+ if (offset !== undefined) {
147
+ sql += `\nOFFSET ?`;
148
+ params.push(offset);
149
+ }
150
+
151
+ return { sql, params };
152
+ }
153
+
154
+ /**
155
+ * Build query for single record by ID with translations
156
+ */
157
+ export function buildTranslationQueryById(
158
+ table: string,
159
+ schema: SchemaDefinition,
160
+ id: number | string,
161
+ lang: string,
162
+ fallbackLang?: string
163
+ ): TranslationQueryResult {
164
+ return buildTranslationQuery({
165
+ table,
166
+ schema,
167
+ lang,
168
+ fallbackLang,
169
+ where: { id },
170
+ limit: 1,
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Build simple query without translations
176
+ */
177
+ function buildSimpleQuery(
178
+ table: string,
179
+ options: {
180
+ where?: Record<string, unknown>;
181
+ orderBy?: OrderByOption[];
182
+ limit?: number;
183
+ offset?: number;
184
+ }
185
+ ): TranslationQueryResult {
186
+ let sql = `SELECT * FROM ${table}`;
187
+ const params: unknown[] = [];
188
+
189
+ if (options.where && Object.keys(options.where).length > 0) {
190
+ const whereResult = buildWhereClause(options.where);
191
+ sql += ` WHERE ${whereResult.sql}`;
192
+ params.push(...whereResult.params);
193
+ }
194
+
195
+ if (options.orderBy && options.orderBy.length > 0) {
196
+ const orderClauses = options.orderBy.map((o) => `${o.field} ${o.direction}`);
197
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
198
+ }
199
+
200
+ if (options.limit !== undefined) {
201
+ sql += ` LIMIT ?`;
202
+ params.push(options.limit);
203
+ }
204
+
205
+ if (options.offset !== undefined) {
206
+ sql += ` OFFSET ?`;
207
+ params.push(options.offset);
208
+ }
209
+
210
+ return { sql, params };
211
+ }
212
+
213
+ /**
214
+ * Build INSERT statement for translation
215
+ */
216
+ export function buildTranslationInsert(
217
+ table: string,
218
+ schema: SchemaDefinition,
219
+ recordId: number | string,
220
+ lang: string,
221
+ data: Record<string, unknown>
222
+ ): TranslationQueryResult {
223
+ const tableSchema = schema.tables[table];
224
+ if (!tableSchema) {
225
+ throw new Error(`Table not found in schema: ${table}`);
226
+ }
227
+
228
+ const translatableFields = getTranslatableFields(tableSchema);
229
+ const transTable = toTranslationTableName(table);
230
+ const fkName = toTranslationFKName(table);
231
+
232
+ const fields = [fkName, "language_code"];
233
+ const values: unknown[] = [recordId, lang];
234
+
235
+ for (const field of translatableFields) {
236
+ if (data[field] !== undefined) {
237
+ fields.push(field);
238
+ values.push(data[field]);
239
+ }
240
+ }
241
+
242
+ const placeholders = fields.map(() => "?").join(", ");
243
+ const sql = `INSERT INTO ${transTable} (${fields.join(", ")}) VALUES (${placeholders})`;
244
+
245
+ return { sql, params: values };
246
+ }
247
+
248
+ /**
249
+ * Build UPSERT statement for translation (INSERT OR REPLACE)
250
+ */
251
+ export function buildTranslationUpsert(
252
+ table: string,
253
+ schema: SchemaDefinition,
254
+ recordId: number | string,
255
+ lang: string,
256
+ data: Record<string, unknown>
257
+ ): TranslationQueryResult {
258
+ const tableSchema = schema.tables[table];
259
+ if (!tableSchema) {
260
+ throw new Error(`Table not found in schema: ${table}`);
261
+ }
262
+
263
+ const translatableFields = getTranslatableFields(tableSchema);
264
+ const transTable = toTranslationTableName(table);
265
+ const fkName = toTranslationFKName(table);
266
+
267
+ const fields = [fkName, "language_code"];
268
+ const values: unknown[] = [recordId, lang];
269
+
270
+ for (const field of translatableFields) {
271
+ if (data[field] !== undefined) {
272
+ fields.push(field);
273
+ values.push(data[field]);
274
+ }
275
+ }
276
+
277
+ const placeholders = fields.map(() => "?").join(", ");
278
+ const sql = `INSERT OR REPLACE INTO ${transTable} (${fields.join(", ")}) VALUES (${placeholders})`;
279
+
280
+ return { sql, params: values };
281
+ }
282
+
283
+ /**
284
+ * Extract translatable fields from data object
285
+ */
286
+ export function extractTranslatableData(
287
+ data: Record<string, unknown>,
288
+ tableSchema: TableDefinition
289
+ ): {
290
+ mainData: Record<string, unknown>;
291
+ translatableData: Record<string, unknown>;
292
+ } {
293
+ const translatableFields = getTranslatableFields(tableSchema);
294
+ const mainData: Record<string, unknown> = {};
295
+ const translatableData: Record<string, unknown> = {};
296
+
297
+ for (const [key, value] of Object.entries(data)) {
298
+ if (translatableFields.includes(key)) {
299
+ translatableData[key] = value;
300
+ } else if (key !== "translations") {
301
+ mainData[key] = value;
302
+ }
303
+ }
304
+
305
+ return { mainData, translatableData };
306
+ }