@promakeai/orm 1.0.1 → 1.0.4
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 +56 -25
- package/dist/index.d.ts +2 -1
- package/dist/index.js +77 -12
- package/dist/schema/fieldBuilder.d.ts +37 -1
- package/dist/types.d.ts +23 -2
- package/dist/utils/translationQuery.d.ts +6 -1
- package/dist/utils/whereBuilder.d.ts +1 -0
- package/package.json +1 -1
- package/src/ORM.ts +14 -5
- package/src/index.ts +4 -0
- package/src/schema/fieldBuilder.ts +50 -1
- package/src/schema/schemaHelpers.ts +2 -1
- package/src/types.ts +39 -2
- package/src/utils/deserializer.ts +3 -2
- package/src/utils/jsonConverter.ts +40 -1
- package/src/utils/populateResolver.ts +2 -1
- package/src/utils/translationQuery.ts +62 -44
- package/src/utils/whereBuilder.ts +17 -0
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@ npm install @promakeai/orm
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { defineSchema, f } from '@promakeai/orm';
|
|
15
15
|
|
|
16
|
-
const schema = defineSchema({
|
|
17
|
-
name: 'myapp',
|
|
18
|
-
languages: ['en', 'tr', 'de'],
|
|
16
|
+
const schema = defineSchema({
|
|
17
|
+
name: 'myapp',
|
|
18
|
+
languages: ['en', 'tr', 'de'],
|
|
19
19
|
tables: {
|
|
20
20
|
users: {
|
|
21
21
|
id: f.id(),
|
|
@@ -47,21 +47,45 @@ const schema = defineSchema({
|
|
|
47
47
|
}),
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
|
-
});
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
##
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## JSON Schema (AI-Friendly)
|
|
54
|
+
|
|
55
|
+
The JSON schema format is the runtime-friendly version and supports native
|
|
56
|
+
arrays and typed objects:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"name": "myapp",
|
|
61
|
+
"languages": ["en", "tr"],
|
|
62
|
+
"defaultLanguage": "en",
|
|
63
|
+
"tables": {
|
|
64
|
+
"products": {
|
|
65
|
+
"id": { "type": "id" },
|
|
66
|
+
"tags": { "type": ["string"] },
|
|
67
|
+
"metadata": { "type": { "color": "string", "weight": "number" } },
|
|
68
|
+
"variants": { "type": [{ "sku": "string", "price": "number" }] }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Field Types
|
|
75
|
+
|
|
76
|
+
| Type | SQL | Description |
|
|
77
|
+
|------|-----|-------------|
|
|
78
|
+
| `f.id()` | INTEGER PRIMARY KEY AUTOINCREMENT | Auto-increment primary key |
|
|
79
|
+
| `f.string()` | VARCHAR | Short text |
|
|
80
|
+
| `f.text()` | TEXT | Long text |
|
|
81
|
+
| `f.int()` | INTEGER | Integer |
|
|
82
|
+
| `f.decimal()` | REAL/DECIMAL | Decimal number |
|
|
83
|
+
| `f.bool()` | INTEGER (0/1) | Boolean |
|
|
84
|
+
| `f.timestamp()` | TEXT (ISO) | ISO datetime string |
|
|
85
|
+
| `f.json()` | TEXT | JSON serialized data |
|
|
86
|
+
|
|
87
|
+
JSON schema type syntax supports `string[]`, `number[]`, `boolean[]`, `object`,
|
|
88
|
+
and `object[]` which are stored as JSON in SQL (TEXT) and typed in TS.
|
|
65
89
|
|
|
66
90
|
## Field Modifiers
|
|
67
91
|
|
|
@@ -148,10 +172,14 @@ buildWhereClause({ id: { $nin: [1, 2, 3] } }); // NOT IN (?, ?, ?)
|
|
|
148
172
|
buildWhereClause({ name: { $like: '%john%' } }); // LIKE ?
|
|
149
173
|
buildWhereClause({ name: { $notLike: '%test%' } }); // NOT LIKE ?
|
|
150
174
|
|
|
151
|
-
// Range and null
|
|
152
|
-
buildWhereClause({ price: { $between: [10, 100] } }); // BETWEEN ? AND ?
|
|
153
|
-
buildWhereClause({ deletedAt: { $isNull: true } }); // IS NULL
|
|
154
|
-
buildWhereClause({ email: { $isNull: false } }); // IS NOT NULL
|
|
175
|
+
// Range and null
|
|
176
|
+
buildWhereClause({ price: { $between: [10, 100] } }); // BETWEEN ? AND ?
|
|
177
|
+
buildWhereClause({ deletedAt: { $isNull: true } }); // IS NULL
|
|
178
|
+
buildWhereClause({ email: { $isNull: false } }); // IS NOT NULL
|
|
179
|
+
|
|
180
|
+
// JSON array contains
|
|
181
|
+
buildWhereClause({ tags: { $contains: "sale" } }); // json_each(...) = "sale"
|
|
182
|
+
buildWhereClause({ tags: { $containsAny: ["sale", "new"] } }); // json_each(...) IN (...)
|
|
155
183
|
|
|
156
184
|
// Logical operators
|
|
157
185
|
buildWhereClause({
|
|
@@ -262,9 +290,12 @@ const { sql, params } = buildTranslationQuery('products', schema, {
|
|
|
262
290
|
// LIMIT 10
|
|
263
291
|
```
|
|
264
292
|
|
|
265
|
-
## Populate Resolver
|
|
266
|
-
|
|
267
|
-
Batch-fetches references to prevent N+1 queries:
|
|
293
|
+
## Populate Resolver
|
|
294
|
+
|
|
295
|
+
Batch-fetches references to prevent N+1 queries:
|
|
296
|
+
|
|
297
|
+
Populate options accept a space-separated string, string array, or nested object
|
|
298
|
+
for deeper population.
|
|
268
299
|
|
|
269
300
|
```typescript
|
|
270
301
|
import { resolvePopulate } from '@promakeai/orm';
|
package/dist/index.d.ts
CHANGED
|
@@ -47,5 +47,6 @@ export type { TranslationQueryOptions, TranslationQueryResult, } from "./utils/t
|
|
|
47
47
|
export { resolvePopulate, getPopulatableFields, validatePopulate, } from "./utils/populateResolver";
|
|
48
48
|
export type { PopulateAdapter } from "./utils/populateResolver";
|
|
49
49
|
export { parseJSONSchema } from "./utils/jsonConverter";
|
|
50
|
+
export { isJsonType } from "./types";
|
|
50
51
|
export { deserializeRow, serializeRow } from "./utils/deserializer";
|
|
51
|
-
export type { FieldType, FieldDefinition, FieldReference, FieldBuilderLike, TableDefinition, LanguageConfig, SchemaDefinition, SchemaInput, JSONFieldDefinition, JSONTableDefinition, JSONSchemaDefinition, WhereCondition, OrderByOption, PopulateOption, PopulateNested, QueryOptions, PaginatedResult, ORMConfig, } from "./types";
|
|
52
|
+
export type { FieldType, FieldDefinition, FieldReference, FieldBuilderLike, TableDefinition, LanguageConfig, SchemaDefinition, SchemaInput, JSONFieldType, JSONFieldDefinition, JSONTableDefinition, JSONSchemaDefinition, WhereCondition, OrderByOption, PopulateOption, PopulateNested, QueryOptions, PaginatedResult, ORMConfig, } from "./types";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
function isJsonType(type) {
|
|
3
|
+
return type === "json" || type === "string[]" || type === "number[]" || type === "boolean[]" || type === "object" || type === "object[]";
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
// src/utils/populateResolver.ts
|
|
2
7
|
function getRefInfo(fieldName, field) {
|
|
3
8
|
if (!field.ref)
|
|
@@ -9,7 +14,7 @@ function getRefInfo(fieldName, field) {
|
|
|
9
14
|
field: isString ? "id" : ref.field || "id",
|
|
10
15
|
localField: fieldName,
|
|
11
16
|
targetField: isString ? "id" : ref.field || "id",
|
|
12
|
-
isArray: Array.isArray(field.default) || fieldName.endsWith("Ids")
|
|
17
|
+
isArray: isJsonType(field.type) || Array.isArray(field.default) || fieldName.endsWith("Ids")
|
|
13
18
|
};
|
|
14
19
|
}
|
|
15
20
|
function toPopulatedName(fieldName) {
|
|
@@ -164,7 +169,10 @@ class ORM {
|
|
|
164
169
|
const { populate, ...queryOpts } = opts;
|
|
165
170
|
let records = await this.adapter.list(table, queryOpts);
|
|
166
171
|
if (populate && this.schema) {
|
|
167
|
-
records = await this.resolvePopulateForRecords(records, table, populate
|
|
172
|
+
records = await this.resolvePopulateForRecords(records, table, populate, {
|
|
173
|
+
lang: opts.lang,
|
|
174
|
+
fallbackLang: opts.fallbackLang
|
|
175
|
+
});
|
|
168
176
|
}
|
|
169
177
|
return records;
|
|
170
178
|
}
|
|
@@ -173,7 +181,7 @@ class ORM {
|
|
|
173
181
|
const { populate, ...queryOpts } = opts;
|
|
174
182
|
const record = await this.adapter.get(table, id, queryOpts);
|
|
175
183
|
if (record && populate && this.schema) {
|
|
176
|
-
const [populated] = await this.resolvePopulateForRecords([record], table, populate);
|
|
184
|
+
const [populated] = await this.resolvePopulateForRecords([record], table, populate, { lang: opts.lang, fallbackLang: opts.fallbackLang });
|
|
177
185
|
return populated;
|
|
178
186
|
}
|
|
179
187
|
return record;
|
|
@@ -257,7 +265,7 @@ class ORM {
|
|
|
257
265
|
throw new Error(`Table "${table}" not found in schema`);
|
|
258
266
|
}
|
|
259
267
|
}
|
|
260
|
-
async resolvePopulateForRecords(records, table, populate) {
|
|
268
|
+
async resolvePopulateForRecords(records, table, populate, queryOptions) {
|
|
261
269
|
if (!this.schema)
|
|
262
270
|
return records;
|
|
263
271
|
const validation = validatePopulate(populate, table, this.schema);
|
|
@@ -266,7 +274,11 @@ class ORM {
|
|
|
266
274
|
}
|
|
267
275
|
const adapterWrapper = {
|
|
268
276
|
findMany: (t, opts) => {
|
|
269
|
-
return this.adapter.list(t,
|
|
277
|
+
return this.adapter.list(t, {
|
|
278
|
+
...opts,
|
|
279
|
+
lang: queryOptions?.lang,
|
|
280
|
+
fallbackLang: queryOptions?.fallbackLang
|
|
281
|
+
});
|
|
270
282
|
}
|
|
271
283
|
};
|
|
272
284
|
return resolvePopulate(records, table, populate, this.schema, adapterWrapper);
|
|
@@ -299,7 +311,7 @@ function getPrimaryKeyField(table) {
|
|
|
299
311
|
return entry ? entry[0] : null;
|
|
300
312
|
}
|
|
301
313
|
function getReferenceFields(table) {
|
|
302
|
-
return Object.entries(table.fields).filter(([_, field]) => field.ref).map(([name, field]) => {
|
|
314
|
+
return Object.entries(table.fields).filter(([_, field]) => field.ref && !isJsonType(field.type)).map(([name, field]) => {
|
|
303
315
|
const ref = field.ref;
|
|
304
316
|
if (typeof ref === "string") {
|
|
305
317
|
return [name, { table: ref, field: "id" }];
|
|
@@ -536,7 +548,20 @@ var f = {
|
|
|
536
548
|
decimal: () => new FieldBuilder("decimal"),
|
|
537
549
|
bool: () => new FieldBuilder("bool"),
|
|
538
550
|
timestamp: () => new FieldBuilder("timestamp"),
|
|
539
|
-
json: () => new FieldBuilder("json")
|
|
551
|
+
json: () => new FieldBuilder("json"),
|
|
552
|
+
stringArray: () => new FieldBuilder("string[]"),
|
|
553
|
+
numberArray: () => new FieldBuilder("number[]"),
|
|
554
|
+
boolArray: () => new FieldBuilder("boolean[]"),
|
|
555
|
+
object: (properties) => {
|
|
556
|
+
const builder = new FieldBuilder("object");
|
|
557
|
+
builder["definition"].properties = properties;
|
|
558
|
+
return builder;
|
|
559
|
+
},
|
|
560
|
+
objectArray: (properties) => {
|
|
561
|
+
const builder = new FieldBuilder("object[]");
|
|
562
|
+
builder["definition"].properties = properties;
|
|
563
|
+
return builder;
|
|
564
|
+
}
|
|
540
565
|
};
|
|
541
566
|
|
|
542
567
|
// src/schema/defineSchema.ts
|
|
@@ -740,6 +765,20 @@ function processFieldOperators(field, operators) {
|
|
|
740
765
|
params.push(value[0], value[1]);
|
|
741
766
|
} else if (op === "$isNull") {
|
|
742
767
|
parts.push(value ? `${field} IS NULL` : `${field} IS NOT NULL`);
|
|
768
|
+
} else if (op === "$contains") {
|
|
769
|
+
parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value = ?)`);
|
|
770
|
+
params.push(value);
|
|
771
|
+
} else if (op === "$containsAny") {
|
|
772
|
+
if (!Array.isArray(value)) {
|
|
773
|
+
throw new Error("$containsAny operator requires an array value");
|
|
774
|
+
}
|
|
775
|
+
if (value.length === 0) {
|
|
776
|
+
parts.push("0 = 1");
|
|
777
|
+
} else {
|
|
778
|
+
const placeholders = value.map(() => "?").join(", ");
|
|
779
|
+
parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value IN (${placeholders}))`);
|
|
780
|
+
params.push(...value);
|
|
781
|
+
}
|
|
743
782
|
} else if (op === "$not") {
|
|
744
783
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
745
784
|
throw new Error("$not operator requires an object with operators");
|
|
@@ -813,6 +852,7 @@ function buildTranslationQuery(options) {
|
|
|
813
852
|
schema,
|
|
814
853
|
lang,
|
|
815
854
|
fallbackLang = schema.languages.default,
|
|
855
|
+
mainFallbackFields,
|
|
816
856
|
where,
|
|
817
857
|
orderBy,
|
|
818
858
|
limit,
|
|
@@ -831,10 +871,15 @@ function buildTranslationQuery(options) {
|
|
|
831
871
|
const mainAlias = "m";
|
|
832
872
|
const transAlias = "t";
|
|
833
873
|
const fallbackAlias = "fb";
|
|
874
|
+
const mainFallbackSet = mainFallbackFields !== undefined ? new Set(mainFallbackFields) : null;
|
|
834
875
|
const selectFields = [];
|
|
835
876
|
for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
|
|
836
877
|
if (field.translatable) {
|
|
837
|
-
|
|
878
|
+
if (mainFallbackSet && !mainFallbackSet.has(fieldName)) {
|
|
879
|
+
selectFields.push(`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`);
|
|
880
|
+
} else {
|
|
881
|
+
selectFields.push(`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}, ${mainAlias}.${fieldName}) as ${fieldName}`);
|
|
882
|
+
}
|
|
838
883
|
} else {
|
|
839
884
|
selectFields.push(`${mainAlias}.${fieldName}`);
|
|
840
885
|
}
|
|
@@ -876,12 +921,13 @@ OFFSET ?`;
|
|
|
876
921
|
}
|
|
877
922
|
return { sql, params };
|
|
878
923
|
}
|
|
879
|
-
function buildTranslationQueryById(table, schema, id, lang, fallbackLang) {
|
|
924
|
+
function buildTranslationQueryById(table, schema, id, lang, fallbackLang, mainFallbackFields) {
|
|
880
925
|
return buildTranslationQuery({
|
|
881
926
|
table,
|
|
882
927
|
schema,
|
|
883
928
|
lang,
|
|
884
929
|
fallbackLang,
|
|
930
|
+
mainFallbackFields,
|
|
885
931
|
where: { id },
|
|
886
932
|
limit: 1
|
|
887
933
|
});
|
|
@@ -984,6 +1030,22 @@ function parseJSONSchema(json) {
|
|
|
984
1030
|
tables
|
|
985
1031
|
};
|
|
986
1032
|
}
|
|
1033
|
+
function normalizeJsonType(type) {
|
|
1034
|
+
if (Array.isArray(type)) {
|
|
1035
|
+
const first = type[0];
|
|
1036
|
+
if (typeof first === "string") {
|
|
1037
|
+
const arrayType = `${first}[]`;
|
|
1038
|
+
return { type: arrayType };
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof first === "object" && first !== null) {
|
|
1041
|
+
return { type: "object[]", properties: first };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (typeof type === "object" && type !== null && !Array.isArray(type)) {
|
|
1045
|
+
return { type: "object", properties: type };
|
|
1046
|
+
}
|
|
1047
|
+
return { type };
|
|
1048
|
+
}
|
|
987
1049
|
function convertJSONField(jsonField) {
|
|
988
1050
|
let ref;
|
|
989
1051
|
if (jsonField.ref) {
|
|
@@ -998,13 +1060,15 @@ function convertJSONField(jsonField) {
|
|
|
998
1060
|
};
|
|
999
1061
|
}
|
|
1000
1062
|
}
|
|
1063
|
+
const { type, properties } = normalizeJsonType(jsonField.type);
|
|
1001
1064
|
return {
|
|
1002
|
-
type
|
|
1065
|
+
type,
|
|
1003
1066
|
nullable: jsonField.nullable ?? !jsonField.required,
|
|
1004
1067
|
unique: jsonField.unique ?? false,
|
|
1005
1068
|
primary: jsonField.primary ?? false,
|
|
1006
1069
|
default: jsonField.default,
|
|
1007
1070
|
translatable: jsonField.translatable ?? false,
|
|
1071
|
+
properties,
|
|
1008
1072
|
ref
|
|
1009
1073
|
};
|
|
1010
1074
|
}
|
|
@@ -1015,7 +1079,7 @@ function deserializeValue(value, fieldType) {
|
|
|
1015
1079
|
if (fieldType === "bool") {
|
|
1016
1080
|
return value === 1 || value === true;
|
|
1017
1081
|
}
|
|
1018
|
-
if (fieldType
|
|
1082
|
+
if (isJsonType(fieldType) && typeof value === "string") {
|
|
1019
1083
|
try {
|
|
1020
1084
|
return JSON.parse(value);
|
|
1021
1085
|
} catch {
|
|
@@ -1039,7 +1103,7 @@ function serializeValue(value, fieldType) {
|
|
|
1039
1103
|
if (fieldType === "bool") {
|
|
1040
1104
|
return value ? 1 : 0;
|
|
1041
1105
|
}
|
|
1042
|
-
if (fieldType
|
|
1106
|
+
if (isJsonType(fieldType) && typeof value !== "string") {
|
|
1043
1107
|
return JSON.stringify(value);
|
|
1044
1108
|
}
|
|
1045
1109
|
return value;
|
|
@@ -1073,6 +1137,7 @@ export {
|
|
|
1073
1137
|
mergeSchemas,
|
|
1074
1138
|
isValidSchema,
|
|
1075
1139
|
isRequiredField,
|
|
1140
|
+
isJsonType,
|
|
1076
1141
|
hasTranslatableFields,
|
|
1077
1142
|
getTranslationTableFields,
|
|
1078
1143
|
getTranslatableFields,
|
|
@@ -146,7 +146,43 @@ export declare const f: {
|
|
|
146
146
|
*/
|
|
147
147
|
timestamp: () => FieldBuilder;
|
|
148
148
|
/**
|
|
149
|
-
* JSON field (TEXT with JSON serialization)
|
|
149
|
+
* JSON field (TEXT with JSON serialization) — untyped, generates Record<string, unknown>
|
|
150
150
|
*/
|
|
151
151
|
json: () => FieldBuilder;
|
|
152
|
+
/**
|
|
153
|
+
* String array field — stored as JSON TEXT, generates string[]
|
|
154
|
+
*/
|
|
155
|
+
stringArray: () => FieldBuilder;
|
|
156
|
+
/**
|
|
157
|
+
* Number array field — stored as JSON TEXT, generates number[]
|
|
158
|
+
*/
|
|
159
|
+
numberArray: () => FieldBuilder;
|
|
160
|
+
/**
|
|
161
|
+
* Boolean array field — stored as JSON TEXT, generates boolean[]
|
|
162
|
+
*/
|
|
163
|
+
boolArray: () => FieldBuilder;
|
|
164
|
+
/**
|
|
165
|
+
* Typed object field — stored as JSON TEXT, generates typed interface
|
|
166
|
+
* @param properties - Property definitions. Keys ending with "?" are optional.
|
|
167
|
+
* Values: "string" | "number" | "boolean"
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```typescript
|
|
171
|
+
* f.object({ key: "string", value: "number" })
|
|
172
|
+
* // Generates: { key: string; value: number }
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
object: (properties: Record<string, string>) => FieldBuilder;
|
|
176
|
+
/**
|
|
177
|
+
* Typed object array field — stored as JSON TEXT, generates Array<{...}>
|
|
178
|
+
* @param properties - Property definitions. Keys ending with "?" are optional.
|
|
179
|
+
* Values: "string" | "number" | "boolean"
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* f.objectArray({ id: "number", slug: "string", "name?": "string" })
|
|
184
|
+
* // Generates: Array<{ id: number; slug: string; name?: string }>
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
objectArray: (properties: Record<string, string>) => FieldBuilder;
|
|
152
188
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
7
|
* Supported field types
|
|
8
|
+
*
|
|
9
|
+
* Primitive types map directly to SQL column types.
|
|
10
|
+
* Typed JSON types (string[], object[], etc.) store as TEXT in SQL
|
|
11
|
+
* but generate accurate TypeScript types.
|
|
12
|
+
*/
|
|
13
|
+
export type FieldType = "id" | "string" | "text" | "int" | "decimal" | "bool" | "timestamp" | "json" | "string[]" | "number[]" | "boolean[]" | "object" | "object[]";
|
|
14
|
+
/**
|
|
15
|
+
* Check if a field type is JSON-like (stored as TEXT in SQL, serialized as JSON)
|
|
8
16
|
*/
|
|
9
|
-
export type FieldType
|
|
17
|
+
export declare function isJsonType(type: FieldType): boolean;
|
|
10
18
|
/**
|
|
11
19
|
* Foreign key reference definition (MongoDB-style)
|
|
12
20
|
*/
|
|
@@ -26,6 +34,7 @@ export interface FieldDefinition {
|
|
|
26
34
|
primary: boolean;
|
|
27
35
|
default?: unknown;
|
|
28
36
|
translatable: boolean;
|
|
37
|
+
properties?: Record<string, string>;
|
|
29
38
|
ref?: string | FieldReference;
|
|
30
39
|
required?: boolean;
|
|
31
40
|
trim?: boolean;
|
|
@@ -74,11 +83,21 @@ export interface SchemaInput {
|
|
|
74
83
|
languages: string[] | LanguageConfig;
|
|
75
84
|
tables: Record<string, Record<string, FieldBuilderLike>>;
|
|
76
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* JSON-native type syntax for schema.json
|
|
88
|
+
*
|
|
89
|
+
* Accepts:
|
|
90
|
+
* - string: primitive type ("string", "int", "json", etc.)
|
|
91
|
+
* - ["string"]: primitive array
|
|
92
|
+
* - [{ id: "number", name: "string" }]: typed object array
|
|
93
|
+
* - { key: "string", value: "number" }: typed object
|
|
94
|
+
*/
|
|
95
|
+
export type JSONFieldType = FieldType | [string] | [Record<string, string>] | Record<string, string>;
|
|
77
96
|
/**
|
|
78
97
|
* JSON Schema Field Definition
|
|
79
98
|
*/
|
|
80
99
|
export interface JSONFieldDefinition {
|
|
81
|
-
type:
|
|
100
|
+
type: JSONFieldType;
|
|
82
101
|
translatable?: boolean;
|
|
83
102
|
required?: boolean;
|
|
84
103
|
unique?: boolean;
|
|
@@ -136,6 +155,8 @@ export interface WhereCondition {
|
|
|
136
155
|
$notLike?: string;
|
|
137
156
|
$between?: [unknown, unknown];
|
|
138
157
|
$isNull?: boolean;
|
|
158
|
+
$contains?: unknown;
|
|
159
|
+
$containsAny?: unknown[];
|
|
139
160
|
$not?: WhereCondition;
|
|
140
161
|
$and?: WhereCondition[];
|
|
141
162
|
$or?: WhereCondition[];
|
|
@@ -13,6 +13,11 @@ export interface TranslationQueryOptions {
|
|
|
13
13
|
schema: SchemaDefinition;
|
|
14
14
|
lang: string;
|
|
15
15
|
fallbackLang?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Main-table fields that can be safely used as cache fallback in COALESCE.
|
|
18
|
+
* If omitted, all translatable fields are assumed available in main table.
|
|
19
|
+
*/
|
|
20
|
+
mainFallbackFields?: string[];
|
|
16
21
|
where?: Record<string, unknown>;
|
|
17
22
|
orderBy?: OrderByOption[];
|
|
18
23
|
limit?: number;
|
|
@@ -32,7 +37,7 @@ export declare function buildTranslationQuery(options: TranslationQueryOptions):
|
|
|
32
37
|
/**
|
|
33
38
|
* Build query for single record by ID with translations
|
|
34
39
|
*/
|
|
35
|
-
export declare function buildTranslationQueryById(table: string, schema: SchemaDefinition, id: number | string, lang: string, fallbackLang?: string): TranslationQueryResult;
|
|
40
|
+
export declare function buildTranslationQueryById(table: string, schema: SchemaDefinition, id: number | string, lang: string, fallbackLang?: string, mainFallbackFields?: string[]): TranslationQueryResult;
|
|
36
41
|
/**
|
|
37
42
|
* Build INSERT statement for translation
|
|
38
43
|
*/
|
package/package.json
CHANGED
package/src/ORM.ts
CHANGED
|
@@ -100,7 +100,10 @@ export class ORM {
|
|
|
100
100
|
|
|
101
101
|
// Resolve populate if schema is available
|
|
102
102
|
if (populate && this.schema) {
|
|
103
|
-
records = await this.resolvePopulateForRecords(records, table, populate
|
|
103
|
+
records = await this.resolvePopulateForRecords(records, table, populate, {
|
|
104
|
+
lang: opts.lang,
|
|
105
|
+
fallbackLang: opts.fallbackLang,
|
|
106
|
+
});
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
return records;
|
|
@@ -132,7 +135,8 @@ export class ORM {
|
|
|
132
135
|
const [populated] = await this.resolvePopulateForRecords(
|
|
133
136
|
[record],
|
|
134
137
|
table,
|
|
135
|
-
populate
|
|
138
|
+
populate,
|
|
139
|
+
{ lang: opts.lang, fallbackLang: opts.fallbackLang }
|
|
136
140
|
);
|
|
137
141
|
return populated;
|
|
138
142
|
}
|
|
@@ -346,7 +350,8 @@ export class ORM {
|
|
|
346
350
|
private async resolvePopulateForRecords<T>(
|
|
347
351
|
records: T[],
|
|
348
352
|
table: string,
|
|
349
|
-
populate: QueryOptions["populate"]
|
|
353
|
+
populate: QueryOptions["populate"],
|
|
354
|
+
queryOptions?: { lang?: string; fallbackLang?: string }
|
|
350
355
|
): Promise<T[]> {
|
|
351
356
|
if (!this.schema) return records;
|
|
352
357
|
|
|
@@ -356,13 +361,17 @@ export class ORM {
|
|
|
356
361
|
console.warn("Populate validation warnings:", validation.errors);
|
|
357
362
|
}
|
|
358
363
|
|
|
359
|
-
// Create adapter wrapper for populate resolver
|
|
364
|
+
// Create adapter wrapper for populate resolver (passes lang for translations)
|
|
360
365
|
const adapterWrapper = {
|
|
361
366
|
findMany: <R = unknown>(
|
|
362
367
|
t: string,
|
|
363
368
|
opts?: { where?: Record<string, unknown> }
|
|
364
369
|
): Promise<R[]> => {
|
|
365
|
-
return this.adapter.list<R>(t,
|
|
370
|
+
return this.adapter.list<R>(t, {
|
|
371
|
+
...opts,
|
|
372
|
+
lang: queryOptions?.lang,
|
|
373
|
+
fallbackLang: queryOptions?.fallbackLang,
|
|
374
|
+
});
|
|
366
375
|
},
|
|
367
376
|
};
|
|
368
377
|
|
package/src/index.ts
CHANGED
|
@@ -116,6 +116,9 @@ export type { PopulateAdapter } from "./utils/populateResolver";
|
|
|
116
116
|
// JSON Schema Converter
|
|
117
117
|
export { parseJSONSchema } from "./utils/jsonConverter";
|
|
118
118
|
|
|
119
|
+
// Type Helpers
|
|
120
|
+
export { isJsonType } from "./types";
|
|
121
|
+
|
|
119
122
|
// Row Serializer/Deserializer
|
|
120
123
|
export { deserializeRow, serializeRow } from "./utils/deserializer";
|
|
121
124
|
|
|
@@ -134,6 +137,7 @@ export type {
|
|
|
134
137
|
SchemaInput,
|
|
135
138
|
|
|
136
139
|
// JSON Schema types (AI-friendly)
|
|
140
|
+
JSONFieldType,
|
|
137
141
|
JSONFieldDefinition,
|
|
138
142
|
JSONTableDefinition,
|
|
139
143
|
JSONSchemaDefinition,
|
|
@@ -237,8 +237,57 @@ export const f = {
|
|
|
237
237
|
timestamp: (): FieldBuilder => new FieldBuilder("timestamp"),
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
* JSON field (TEXT with JSON serialization)
|
|
240
|
+
* JSON field (TEXT with JSON serialization) — untyped, generates Record<string, unknown>
|
|
241
241
|
*/
|
|
242
242
|
json: (): FieldBuilder => new FieldBuilder("json"),
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* String array field — stored as JSON TEXT, generates string[]
|
|
246
|
+
*/
|
|
247
|
+
stringArray: (): FieldBuilder => new FieldBuilder("string[]"),
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Number array field — stored as JSON TEXT, generates number[]
|
|
251
|
+
*/
|
|
252
|
+
numberArray: (): FieldBuilder => new FieldBuilder("number[]"),
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Boolean array field — stored as JSON TEXT, generates boolean[]
|
|
256
|
+
*/
|
|
257
|
+
boolArray: (): FieldBuilder => new FieldBuilder("boolean[]"),
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Typed object field — stored as JSON TEXT, generates typed interface
|
|
261
|
+
* @param properties - Property definitions. Keys ending with "?" are optional.
|
|
262
|
+
* Values: "string" | "number" | "boolean"
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```typescript
|
|
266
|
+
* f.object({ key: "string", value: "number" })
|
|
267
|
+
* // Generates: { key: string; value: number }
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
object: (properties: Record<string, string>): FieldBuilder => {
|
|
271
|
+
const builder = new FieldBuilder("object");
|
|
272
|
+
builder["definition"].properties = properties;
|
|
273
|
+
return builder;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Typed object array field — stored as JSON TEXT, generates Array<{...}>
|
|
278
|
+
* @param properties - Property definitions. Keys ending with "?" are optional.
|
|
279
|
+
* Values: "string" | "number" | "boolean"
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```typescript
|
|
283
|
+
* f.objectArray({ id: "number", slug: "string", "name?": "string" })
|
|
284
|
+
* // Generates: Array<{ id: number; slug: string; name?: string }>
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
objectArray: (properties: Record<string, string>): FieldBuilder => {
|
|
288
|
+
const builder = new FieldBuilder("object[]");
|
|
289
|
+
builder["definition"].properties = properties;
|
|
290
|
+
return builder;
|
|
291
|
+
},
|
|
243
292
|
};
|
|
244
293
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TableDefinition, FieldDefinition, FieldReference } from "../types";
|
|
8
|
+
import { isJsonType } from "../types";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Get names of translatable fields in a table
|
|
@@ -55,7 +56,7 @@ export function getReferenceFields(
|
|
|
55
56
|
table: TableDefinition
|
|
56
57
|
): [string, FieldReference][] {
|
|
57
58
|
return Object.entries(table.fields)
|
|
58
|
-
.filter(([_, field]) => field.ref)
|
|
59
|
+
.filter(([_, field]) => field.ref && !isJsonType(field.type))
|
|
59
60
|
.map(([name, field]) => {
|
|
60
61
|
const ref = field.ref!;
|
|
61
62
|
// Normalize string to FieldReference
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Supported field types
|
|
11
|
+
*
|
|
12
|
+
* Primitive types map directly to SQL column types.
|
|
13
|
+
* Typed JSON types (string[], object[], etc.) store as TEXT in SQL
|
|
14
|
+
* but generate accurate TypeScript types.
|
|
11
15
|
*/
|
|
12
16
|
export type FieldType =
|
|
13
17
|
| "id"
|
|
@@ -17,7 +21,19 @@ export type FieldType =
|
|
|
17
21
|
| "decimal"
|
|
18
22
|
| "bool"
|
|
19
23
|
| "timestamp"
|
|
20
|
-
| "json"
|
|
24
|
+
| "json"
|
|
25
|
+
| "string[]"
|
|
26
|
+
| "number[]"
|
|
27
|
+
| "boolean[]"
|
|
28
|
+
| "object"
|
|
29
|
+
| "object[]";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a field type is JSON-like (stored as TEXT in SQL, serialized as JSON)
|
|
33
|
+
*/
|
|
34
|
+
export function isJsonType(type: FieldType): boolean {
|
|
35
|
+
return type === "json" || type === "string[]" || type === "number[]" || type === "boolean[]" || type === "object" || type === "object[]";
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
39
|
* Foreign key reference definition (MongoDB-style)
|
|
@@ -40,6 +56,10 @@ export interface FieldDefinition {
|
|
|
40
56
|
default?: unknown;
|
|
41
57
|
translatable: boolean;
|
|
42
58
|
|
|
59
|
+
// Typed JSON properties (for "object" and "object[]" types)
|
|
60
|
+
// Keys ending with "?" are optional. Values: "string" | "number" | "boolean"
|
|
61
|
+
properties?: Record<string, string>;
|
|
62
|
+
|
|
43
63
|
// Reference (MongoDB-style)
|
|
44
64
|
ref?: string | FieldReference;
|
|
45
65
|
|
|
@@ -101,11 +121,26 @@ export interface SchemaInput {
|
|
|
101
121
|
|
|
102
122
|
// ==================== JSON Schema Types (AI Agent Friendly) ====================
|
|
103
123
|
|
|
124
|
+
/**
|
|
125
|
+
* JSON-native type syntax for schema.json
|
|
126
|
+
*
|
|
127
|
+
* Accepts:
|
|
128
|
+
* - string: primitive type ("string", "int", "json", etc.)
|
|
129
|
+
* - ["string"]: primitive array
|
|
130
|
+
* - [{ id: "number", name: "string" }]: typed object array
|
|
131
|
+
* - { key: "string", value: "number" }: typed object
|
|
132
|
+
*/
|
|
133
|
+
export type JSONFieldType =
|
|
134
|
+
| FieldType
|
|
135
|
+
| [string]
|
|
136
|
+
| [Record<string, string>]
|
|
137
|
+
| Record<string, string>;
|
|
138
|
+
|
|
104
139
|
/**
|
|
105
140
|
* JSON Schema Field Definition
|
|
106
141
|
*/
|
|
107
142
|
export interface JSONFieldDefinition {
|
|
108
|
-
type:
|
|
143
|
+
type: JSONFieldType;
|
|
109
144
|
translatable?: boolean;
|
|
110
145
|
required?: boolean;
|
|
111
146
|
unique?: boolean;
|
|
@@ -176,6 +211,8 @@ export interface WhereCondition {
|
|
|
176
211
|
$notLike?: string;
|
|
177
212
|
$between?: [unknown, unknown];
|
|
178
213
|
$isNull?: boolean;
|
|
214
|
+
$contains?: unknown;
|
|
215
|
+
$containsAny?: unknown[];
|
|
179
216
|
$not?: WhereCondition;
|
|
180
217
|
$and?: WhereCondition[];
|
|
181
218
|
$or?: WhereCondition[];
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { FieldType, FieldDefinition } from "../types";
|
|
10
|
+
import { isJsonType } from "../types";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Deserialize a single value from DB storage type to application type
|
|
@@ -18,7 +19,7 @@ function deserializeValue(value: unknown, fieldType: FieldType): unknown {
|
|
|
18
19
|
return value === 1 || value === true;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
if (fieldType
|
|
22
|
+
if (isJsonType(fieldType) && typeof value === "string") {
|
|
22
23
|
try {
|
|
23
24
|
return JSON.parse(value);
|
|
24
25
|
} catch {
|
|
@@ -61,7 +62,7 @@ function serializeValue(value: unknown, fieldType: FieldType): unknown {
|
|
|
61
62
|
return value ? 1 : 0;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
if (fieldType
|
|
65
|
+
if (isJsonType(fieldType) && typeof value !== "string") {
|
|
65
66
|
return JSON.stringify(value);
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import type {
|
|
9
9
|
JSONSchemaDefinition,
|
|
10
10
|
JSONFieldDefinition,
|
|
11
|
+
JSONFieldType,
|
|
11
12
|
SchemaDefinition,
|
|
12
13
|
FieldDefinition,
|
|
14
|
+
FieldType,
|
|
13
15
|
TableDefinition,
|
|
14
16
|
LanguageConfig,
|
|
15
17
|
} from "../types";
|
|
@@ -63,6 +65,40 @@ export function parseJSONSchema(json: JSONSchemaDefinition): SchemaDefinition {
|
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Normalize JSON-native type syntax to internal FieldType + properties
|
|
70
|
+
*
|
|
71
|
+
* - ["string"] → type: "string[]"
|
|
72
|
+
* - ["number"] → type: "number[]"
|
|
73
|
+
* - ["boolean"] → type: "boolean[]"
|
|
74
|
+
* - [{ id: "number", ... }] → type: "object[]", properties: { id: "number", ... }
|
|
75
|
+
* - { key: "string", ... } → type: "object", properties: { key: "string", ... }
|
|
76
|
+
* - "string" / "json" / ... → pass through as-is
|
|
77
|
+
*/
|
|
78
|
+
function normalizeJsonType(type: JSONFieldType): { type: FieldType; properties?: Record<string, string> } {
|
|
79
|
+
// Array syntax: ["string"] or [{ ... }]
|
|
80
|
+
if (Array.isArray(type)) {
|
|
81
|
+
const first = type[0];
|
|
82
|
+
if (typeof first === "string") {
|
|
83
|
+
// Primitive array: ["string"] → "string[]"
|
|
84
|
+
const arrayType = `${first}[]` as FieldType;
|
|
85
|
+
return { type: arrayType };
|
|
86
|
+
}
|
|
87
|
+
if (typeof first === "object" && first !== null) {
|
|
88
|
+
// Object array: [{ id: "number", ... }] → "object[]" + properties
|
|
89
|
+
return { type: "object[]", properties: first as Record<string, string> };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Object syntax: { key: "string", ... } → "object" + properties
|
|
94
|
+
if (typeof type === "object" && type !== null && !Array.isArray(type)) {
|
|
95
|
+
return { type: "object", properties: type as Record<string, string> };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// String: pass through as-is
|
|
99
|
+
return { type: type as FieldType };
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
/**
|
|
67
103
|
* Convert single JSON field to internal FieldDefinition
|
|
68
104
|
*/
|
|
@@ -82,13 +118,16 @@ function convertJSONField(jsonField: JSONFieldDefinition): FieldDefinition {
|
|
|
82
118
|
}
|
|
83
119
|
}
|
|
84
120
|
|
|
121
|
+
const { type, properties } = normalizeJsonType(jsonField.type);
|
|
122
|
+
|
|
85
123
|
return {
|
|
86
|
-
type
|
|
124
|
+
type,
|
|
87
125
|
nullable: jsonField.nullable ?? !jsonField.required,
|
|
88
126
|
unique: jsonField.unique ?? false,
|
|
89
127
|
primary: jsonField.primary ?? false,
|
|
90
128
|
default: jsonField.default,
|
|
91
129
|
translatable: jsonField.translatable ?? false,
|
|
130
|
+
properties,
|
|
92
131
|
ref,
|
|
93
132
|
};
|
|
94
133
|
}
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
PopulateOption,
|
|
32
32
|
PopulateNested,
|
|
33
33
|
} from "../types";
|
|
34
|
+
import { isJsonType } from "../types";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Adapter interface for fetching records
|
|
@@ -79,7 +80,7 @@ function getRefInfo(
|
|
|
79
80
|
field: isString ? "id" : ref.field || "id",
|
|
80
81
|
localField: fieldName,
|
|
81
82
|
targetField: isString ? "id" : ref.field || "id",
|
|
82
|
-
isArray: Array.isArray(field.default) || fieldName.endsWith("Ids"),
|
|
83
|
+
isArray: isJsonType(field.type) || Array.isArray(field.default) || fieldName.endsWith("Ids"),
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -44,16 +44,21 @@ function buildAliasedWhereClause(
|
|
|
44
44
|
/**
|
|
45
45
|
* Options for translation queries
|
|
46
46
|
*/
|
|
47
|
-
export interface TranslationQueryOptions {
|
|
48
|
-
table: string;
|
|
49
|
-
schema: SchemaDefinition;
|
|
50
|
-
lang: string;
|
|
51
|
-
fallbackLang?: string;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
export interface TranslationQueryOptions {
|
|
48
|
+
table: string;
|
|
49
|
+
schema: SchemaDefinition;
|
|
50
|
+
lang: string;
|
|
51
|
+
fallbackLang?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Main-table fields that can be safely used as cache fallback in COALESCE.
|
|
54
|
+
* If omitted, all translatable fields are assumed available in main table.
|
|
55
|
+
*/
|
|
56
|
+
mainFallbackFields?: string[];
|
|
57
|
+
where?: Record<string, unknown>;
|
|
58
|
+
orderBy?: OrderByOption[];
|
|
59
|
+
limit?: number;
|
|
60
|
+
offset?: number;
|
|
61
|
+
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
64
|
* Query result with SQL and parameters
|
|
@@ -73,12 +78,13 @@ export function buildTranslationQuery(
|
|
|
73
78
|
table,
|
|
74
79
|
schema,
|
|
75
80
|
lang,
|
|
76
|
-
fallbackLang = schema.languages.default,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
fallbackLang = schema.languages.default,
|
|
82
|
+
mainFallbackFields,
|
|
83
|
+
where,
|
|
84
|
+
orderBy,
|
|
85
|
+
limit,
|
|
86
|
+
offset,
|
|
87
|
+
} = options;
|
|
82
88
|
|
|
83
89
|
const tableSchema = schema.tables[table];
|
|
84
90
|
if (!tableSchema) {
|
|
@@ -94,22 +100,32 @@ export function buildTranslationQuery(
|
|
|
94
100
|
const transTable = toTranslationTableName(table);
|
|
95
101
|
const fkName = toTranslationFKName(table);
|
|
96
102
|
|
|
97
|
-
const mainAlias = "m";
|
|
98
|
-
const transAlias = "t";
|
|
99
|
-
const fallbackAlias = "fb";
|
|
103
|
+
const mainAlias = "m";
|
|
104
|
+
const transAlias = "t";
|
|
105
|
+
const fallbackAlias = "fb";
|
|
106
|
+
const mainFallbackSet =
|
|
107
|
+
mainFallbackFields !== undefined
|
|
108
|
+
? new Set(mainFallbackFields)
|
|
109
|
+
: null;
|
|
100
110
|
|
|
101
111
|
// Build SELECT fields
|
|
102
112
|
const selectFields: string[] = [];
|
|
103
113
|
|
|
104
|
-
for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
|
|
105
|
-
if (field.translatable) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
|
|
115
|
+
if (field.translatable) {
|
|
116
|
+
if (mainFallbackSet && !mainFallbackSet.has(fieldName)) {
|
|
117
|
+
selectFields.push(
|
|
118
|
+
`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
selectFields.push(
|
|
122
|
+
`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}, ${mainAlias}.${fieldName}) as ${fieldName}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
selectFields.push(`${mainAlias}.${fieldName}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
113
129
|
|
|
114
130
|
let sql = `SELECT ${selectFields.join(", ")}
|
|
115
131
|
FROM ${table} ${mainAlias}
|
|
@@ -154,22 +170,24 @@ LEFT JOIN ${transTable} ${fallbackAlias}
|
|
|
154
170
|
/**
|
|
155
171
|
* Build query for single record by ID with translations
|
|
156
172
|
*/
|
|
157
|
-
export function buildTranslationQueryById(
|
|
158
|
-
table: string,
|
|
159
|
-
schema: SchemaDefinition,
|
|
160
|
-
id: number | string,
|
|
161
|
-
lang: string,
|
|
162
|
-
fallbackLang?: string
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
export function buildTranslationQueryById(
|
|
174
|
+
table: string,
|
|
175
|
+
schema: SchemaDefinition,
|
|
176
|
+
id: number | string,
|
|
177
|
+
lang: string,
|
|
178
|
+
fallbackLang?: string,
|
|
179
|
+
mainFallbackFields?: string[]
|
|
180
|
+
): TranslationQueryResult {
|
|
181
|
+
return buildTranslationQuery({
|
|
182
|
+
table,
|
|
183
|
+
schema,
|
|
184
|
+
lang,
|
|
185
|
+
fallbackLang,
|
|
186
|
+
mainFallbackFields,
|
|
187
|
+
where: { id },
|
|
188
|
+
limit: 1,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
173
191
|
|
|
174
192
|
/**
|
|
175
193
|
* Build simple query without translations
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - Null: $isNull
|
|
13
13
|
* - Negation: $not (field-level)
|
|
14
14
|
* - Logical: $and, $or, $nor
|
|
15
|
+
* - JSON Array: $contains, $containsAny (for JSON array fields stored as TEXT)
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
export interface WhereResult {
|
|
@@ -68,6 +69,22 @@ function processFieldOperators(
|
|
|
68
69
|
params.push(value[0], value[1]);
|
|
69
70
|
} else if (op === "$isNull") {
|
|
70
71
|
parts.push(value ? `${field} IS NULL` : `${field} IS NOT NULL`);
|
|
72
|
+
} else if (op === "$contains") {
|
|
73
|
+
// JSON array contains a single value: EXISTS (SELECT 1 FROM json_each(field) WHERE value = ?)
|
|
74
|
+
parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value = ?)`);
|
|
75
|
+
params.push(value);
|
|
76
|
+
} else if (op === "$containsAny") {
|
|
77
|
+
// JSON array contains any of the values: EXISTS (SELECT 1 FROM json_each(field) WHERE value IN (?, ?))
|
|
78
|
+
if (!Array.isArray(value)) {
|
|
79
|
+
throw new Error("$containsAny operator requires an array value");
|
|
80
|
+
}
|
|
81
|
+
if (value.length === 0) {
|
|
82
|
+
parts.push("0 = 1");
|
|
83
|
+
} else {
|
|
84
|
+
const placeholders = value.map(() => "?").join(", ");
|
|
85
|
+
parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value IN (${placeholders}))`);
|
|
86
|
+
params.push(...value);
|
|
87
|
+
}
|
|
71
88
|
} else if (op === "$not") {
|
|
72
89
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
73
90
|
throw new Error("$not operator requires an object with operators");
|