@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.
- package/README.md +154 -0
- package/dist/ORM.d.ts +190 -0
- package/dist/adapters/IDataAdapter.d.ts +134 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +1054 -0
- package/dist/schema/defineSchema.d.ts +50 -0
- package/dist/schema/fieldBuilder.d.ts +152 -0
- package/dist/schema/helpers.d.ts +54 -0
- package/dist/schema/index.d.ts +9 -0
- package/dist/schema/schemaHelpers.d.ts +55 -0
- package/dist/schema/validator.d.ts +45 -0
- package/dist/types.d.ts +192 -0
- package/dist/utils/jsonConverter.d.ts +29 -0
- package/dist/utils/populateResolver.d.ts +57 -0
- package/dist/utils/translationQuery.d.ts +50 -0
- package/dist/utils/whereBuilder.d.ts +38 -0
- package/package.json +48 -0
- package/src/ORM.ts +398 -0
- package/src/adapters/IDataAdapter.ts +196 -0
- package/src/index.ts +148 -0
- package/src/schema/defineSchema.ts +164 -0
- package/src/schema/fieldBuilder.ts +244 -0
- package/src/schema/helpers.ts +171 -0
- package/src/schema/index.ts +47 -0
- package/src/schema/schemaHelpers.ts +123 -0
- package/src/schema/validator.ts +189 -0
- package/src/types.ts +243 -0
- package/src/utils/jsonConverter.ts +94 -0
- package/src/utils/populateResolver.ts +322 -0
- package/src/utils/translationQuery.ts +306 -0
- package/src/utils/whereBuilder.ts +154 -0
|
@@ -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
|
+
}
|