@shetty4l/core 0.1.34 → 0.1.36
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/package.json +1 -1
- package/src/state/collection/decorators.ts +252 -0
- package/src/state/collection/query.ts +259 -0
- package/src/state/collection/types.ts +160 -0
- package/src/state/decorators.ts +4 -1
- package/src/state/index.ts +103 -5
- package/src/state/loader.ts +682 -2
- package/src/state/schema.ts +104 -1
- package/src/state/types.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TC39 decorators for collection persistence.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same global accumulator pattern as @Persisted decorators
|
|
5
|
+
* to work around Bun's TC39 decorator quirks.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* @PersistedCollection('users')
|
|
10
|
+
* class User extends CollectionEntity {
|
|
11
|
+
* @Id() id: string = '';
|
|
12
|
+
* @Field('string') @Index() email: string = '';
|
|
13
|
+
* @Field('string') name: string = '';
|
|
14
|
+
* @Field('date') @Index(['status', 'created_at']) createdAt: Date | null = null;
|
|
15
|
+
* @Field('string') status: string = 'active';
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type CollectionFieldMeta,
|
|
22
|
+
collectionMeta,
|
|
23
|
+
type FieldType,
|
|
24
|
+
type IndexMeta,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert camelCase to snake_case.
|
|
29
|
+
* Handles leading uppercase (e.g., 'ID' -> 'id', not '_i_d').
|
|
30
|
+
*/
|
|
31
|
+
function toSnakeCase(str: string): string {
|
|
32
|
+
return str.replace(/[A-Z]/g, (letter, index) =>
|
|
33
|
+
index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract property name from TC39 decorator context.
|
|
39
|
+
*
|
|
40
|
+
* TC39 field decorator context is a ClassFieldDecoratorContext object with a `name` property.
|
|
41
|
+
* This handles both the standard TC39 context object and legacy string contexts.
|
|
42
|
+
*/
|
|
43
|
+
function extractPropertyName(context: unknown): string {
|
|
44
|
+
if (typeof context === "object" && context !== null && "name" in context) {
|
|
45
|
+
return String((context as { name: unknown }).name);
|
|
46
|
+
}
|
|
47
|
+
return String(context);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for the @Field decorator. */
|
|
51
|
+
export interface FieldOptions {
|
|
52
|
+
/** Custom column name. Defaults to snake_case of property name. */
|
|
53
|
+
column?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for the @Id decorator. */
|
|
57
|
+
export interface IdOptions {
|
|
58
|
+
/** Custom column name. Defaults to 'id'. */
|
|
59
|
+
column?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --------------------------------------------------------------------------
|
|
63
|
+
// Global accumulators for pending definitions
|
|
64
|
+
// --------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
type PendingFieldDef = { column: string; type: FieldType };
|
|
67
|
+
type PendingFieldsMap = Map<string, PendingFieldDef>;
|
|
68
|
+
type PendingIdDef = { property: string; column: string; type: FieldType };
|
|
69
|
+
type PendingIndexDef = { columns: string[] };
|
|
70
|
+
|
|
71
|
+
// In Bun's TC39 implementation, field decorators run synchronously
|
|
72
|
+
// before the class decorator for the same class.
|
|
73
|
+
let globalPendingFields: PendingFieldsMap | null = null;
|
|
74
|
+
let globalPendingId: PendingIdDef | null = null;
|
|
75
|
+
let globalPendingIdCount = 0; // Track multiple @Id to detect error
|
|
76
|
+
let globalPendingIndices: PendingIndexDef[] | null = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reset all global accumulator state.
|
|
80
|
+
* Called before throwing errors to prevent stale data affecting subsequent classes.
|
|
81
|
+
*/
|
|
82
|
+
function resetGlobalState(): void {
|
|
83
|
+
globalPendingFields = null;
|
|
84
|
+
globalPendingId = null;
|
|
85
|
+
globalPendingIdCount = 0;
|
|
86
|
+
globalPendingIndices = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mark a class as a persisted collection (multi-row table).
|
|
91
|
+
*
|
|
92
|
+
* @param table - SQLite table name
|
|
93
|
+
* @throws Error if class extends another @PersistedCollection class
|
|
94
|
+
* @throws Error if no @Id field is defined
|
|
95
|
+
*/
|
|
96
|
+
export function PersistedCollection(table: string) {
|
|
97
|
+
// Bun's TC39 decorator: context is undefined, not ClassDecoratorContext
|
|
98
|
+
return function <T extends new (...args: unknown[]) => object>(
|
|
99
|
+
target: T,
|
|
100
|
+
_context: unknown,
|
|
101
|
+
): T {
|
|
102
|
+
// Check prototype chain for existing @PersistedCollection class
|
|
103
|
+
let proto = Object.getPrototypeOf(target);
|
|
104
|
+
while (proto && proto !== Function.prototype) {
|
|
105
|
+
if (collectionMeta.has(proto)) {
|
|
106
|
+
const parentMeta = collectionMeta.get(proto)!;
|
|
107
|
+
resetGlobalState();
|
|
108
|
+
throw new Error(
|
|
109
|
+
`@PersistedCollection class "${target.name}" cannot extend @PersistedCollection class "${proto.name}" (table: "${parentMeta.table}"). ` +
|
|
110
|
+
`Collection classes do not support inheritance.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
proto = Object.getPrototypeOf(proto);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check that @Id was defined
|
|
117
|
+
if (!globalPendingId) {
|
|
118
|
+
resetGlobalState();
|
|
119
|
+
throw new Error(
|
|
120
|
+
`@PersistedCollection class "${target.name}" must have exactly one @Id field.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check that only one @Id was defined for this class
|
|
125
|
+
if (globalPendingIdCount > 1) {
|
|
126
|
+
resetGlobalState();
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Multiple @Id decorators found in "${target.name}". A class can only have one @Id field.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Initialize metadata for this class
|
|
133
|
+
const meta = {
|
|
134
|
+
table,
|
|
135
|
+
idProperty: globalPendingId.property,
|
|
136
|
+
idColumn: globalPendingId.column,
|
|
137
|
+
idType: globalPendingId.type,
|
|
138
|
+
fields: new Map<string, CollectionFieldMeta>(),
|
|
139
|
+
indices: [] as IndexMeta[],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Consume pending fields accumulated by @Field decorators
|
|
143
|
+
if (globalPendingFields) {
|
|
144
|
+
for (const [property, def] of globalPendingFields) {
|
|
145
|
+
meta.fields.set(property, {
|
|
146
|
+
property,
|
|
147
|
+
column: def.column,
|
|
148
|
+
type: def.type,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
globalPendingFields = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Consume pending indices accumulated by @Index decorators
|
|
155
|
+
if (globalPendingIndices) {
|
|
156
|
+
meta.indices = globalPendingIndices;
|
|
157
|
+
globalPendingIndices = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Clear pending id
|
|
161
|
+
globalPendingId = null;
|
|
162
|
+
globalPendingIdCount = 0;
|
|
163
|
+
|
|
164
|
+
collectionMeta.set(target, meta);
|
|
165
|
+
return target;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mark a property as the primary key field.
|
|
171
|
+
*
|
|
172
|
+
* Each @PersistedCollection class must have exactly one @Id field.
|
|
173
|
+
* The field type is inferred from the accompanying @Field decorator,
|
|
174
|
+
* or defaults to 'string' if @Field is not present.
|
|
175
|
+
*
|
|
176
|
+
* @param type - The field type ('string' | 'number' | 'boolean' | 'date'), defaults to 'string'
|
|
177
|
+
* @param options - Optional configuration (column name override)
|
|
178
|
+
*/
|
|
179
|
+
export function Id(type: FieldType = "string", options?: IdOptions) {
|
|
180
|
+
return function (_target: undefined, context: unknown): void {
|
|
181
|
+
const property = extractPropertyName(context);
|
|
182
|
+
const column = options?.column ?? "id";
|
|
183
|
+
|
|
184
|
+
// Track count to detect multiple @Id in same class
|
|
185
|
+
globalPendingIdCount++;
|
|
186
|
+
globalPendingId = { property, column, type };
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Mark a property as a persisted field in a collection.
|
|
192
|
+
*
|
|
193
|
+
* @param type - The field type ('string' | 'number' | 'boolean' | 'date')
|
|
194
|
+
* @param options - Optional field configuration (column name override)
|
|
195
|
+
*/
|
|
196
|
+
export function Field(type: FieldType, options?: FieldOptions) {
|
|
197
|
+
return function (_target: undefined, context: unknown): void {
|
|
198
|
+
const property = extractPropertyName(context);
|
|
199
|
+
const column = options?.column ?? toSnakeCase(property);
|
|
200
|
+
|
|
201
|
+
// Accumulate field definitions
|
|
202
|
+
if (!globalPendingFields) {
|
|
203
|
+
globalPendingFields = new Map();
|
|
204
|
+
}
|
|
205
|
+
globalPendingFields.set(property, { column, type });
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Mark column(s) for indexing.
|
|
211
|
+
*
|
|
212
|
+
* Can be applied multiple times to create multiple indices.
|
|
213
|
+
* When applied to a field, indexes that field. Can also specify
|
|
214
|
+
* composite index columns explicitly.
|
|
215
|
+
*
|
|
216
|
+
* @param columns - Optional column names for composite index.
|
|
217
|
+
* If omitted, indexes the decorated field only.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* // Single column index on email
|
|
222
|
+
* @Field('string') @Index() email: string = '';
|
|
223
|
+
*
|
|
224
|
+
* // Composite index on [status, created_at]
|
|
225
|
+
* @Field('string') @Index(['status', 'created_at']) status: string = '';
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function Index(columns?: string | string[]) {
|
|
229
|
+
return function (_target: undefined, context: unknown): void {
|
|
230
|
+
const property = extractPropertyName(context);
|
|
231
|
+
const column = toSnakeCase(property);
|
|
232
|
+
|
|
233
|
+
// Determine the columns to index
|
|
234
|
+
let indexColumns: string[];
|
|
235
|
+
if (columns === undefined) {
|
|
236
|
+
// Index the decorated field only
|
|
237
|
+
indexColumns = [column];
|
|
238
|
+
} else if (typeof columns === "string") {
|
|
239
|
+
// Single column specified
|
|
240
|
+
indexColumns = [toSnakeCase(columns)];
|
|
241
|
+
} else {
|
|
242
|
+
// Array of columns
|
|
243
|
+
indexColumns = columns.map(toSnakeCase);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Accumulate index definitions
|
|
247
|
+
if (!globalPendingIndices) {
|
|
248
|
+
globalPendingIndices = [];
|
|
249
|
+
}
|
|
250
|
+
globalPendingIndices.push({ columns: indexColumns });
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query builder for collection WHERE and ORDER BY clauses.
|
|
3
|
+
*
|
|
4
|
+
* Produces parameterized SQL to prevent injection attacks.
|
|
5
|
+
* All values are returned as bind parameters, never interpolated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SQLQueryBindings } from "bun:sqlite";
|
|
9
|
+
import type {
|
|
10
|
+
CollectionMeta,
|
|
11
|
+
OrderByClause,
|
|
12
|
+
OrderDirection,
|
|
13
|
+
WhereClause,
|
|
14
|
+
WhereCondition,
|
|
15
|
+
WhereOperator,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result of building a WHERE clause.
|
|
20
|
+
*/
|
|
21
|
+
export interface WhereResult {
|
|
22
|
+
/** SQL WHERE clause fragment (without 'WHERE' keyword). Empty string if no conditions. */
|
|
23
|
+
sql: string;
|
|
24
|
+
/** Bind parameters for the query. */
|
|
25
|
+
params: SQLQueryBindings[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a value is a WhereCondition object.
|
|
30
|
+
*/
|
|
31
|
+
function isWhereCondition<T>(value: unknown): value is WhereCondition<T> {
|
|
32
|
+
return (
|
|
33
|
+
typeof value === "object" &&
|
|
34
|
+
value !== null &&
|
|
35
|
+
"op" in value &&
|
|
36
|
+
typeof (value as WhereCondition<T>).op === "string"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map a property name to its column name using metadata.
|
|
42
|
+
*
|
|
43
|
+
* @param meta - Collection metadata
|
|
44
|
+
* @param property - Property name
|
|
45
|
+
* @returns Column name
|
|
46
|
+
* @throws Error if property not found in metadata
|
|
47
|
+
*/
|
|
48
|
+
function getColumn(meta: CollectionMeta, property: string): string {
|
|
49
|
+
if (property === meta.idProperty) {
|
|
50
|
+
return meta.idColumn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const field = meta.fields.get(property);
|
|
54
|
+
if (!field) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Property "${property}" not found in collection "${meta.table}". ` +
|
|
57
|
+
`Available fields: ${meta.idProperty}, ${[...meta.fields.keys()].join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return field.column;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build SQL condition fragment and params for a single operator.
|
|
65
|
+
*
|
|
66
|
+
* @param column - Column name
|
|
67
|
+
* @param op - Comparison operator
|
|
68
|
+
* @param value - Value for comparison
|
|
69
|
+
* @returns SQL fragment and params tuple
|
|
70
|
+
*/
|
|
71
|
+
function buildOperatorCondition(
|
|
72
|
+
column: string,
|
|
73
|
+
op: WhereOperator,
|
|
74
|
+
value: unknown,
|
|
75
|
+
): { sql: string; params: SQLQueryBindings[] } {
|
|
76
|
+
switch (op) {
|
|
77
|
+
case "eq":
|
|
78
|
+
return { sql: `${column} = ?`, params: [value as SQLQueryBindings] };
|
|
79
|
+
|
|
80
|
+
case "neq":
|
|
81
|
+
return { sql: `${column} != ?`, params: [value as SQLQueryBindings] };
|
|
82
|
+
|
|
83
|
+
case "lt":
|
|
84
|
+
return { sql: `${column} < ?`, params: [value as SQLQueryBindings] };
|
|
85
|
+
|
|
86
|
+
case "lte":
|
|
87
|
+
return { sql: `${column} <= ?`, params: [value as SQLQueryBindings] };
|
|
88
|
+
|
|
89
|
+
case "gt":
|
|
90
|
+
return { sql: `${column} > ?`, params: [value as SQLQueryBindings] };
|
|
91
|
+
|
|
92
|
+
case "gte":
|
|
93
|
+
return { sql: `${column} >= ?`, params: [value as SQLQueryBindings] };
|
|
94
|
+
|
|
95
|
+
case "in": {
|
|
96
|
+
const arr = value as SQLQueryBindings[];
|
|
97
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
98
|
+
// Empty IN clause: always false
|
|
99
|
+
return { sql: "0 = 1", params: [] };
|
|
100
|
+
}
|
|
101
|
+
const placeholders = arr.map(() => "?").join(", ");
|
|
102
|
+
return { sql: `${column} IN (${placeholders})`, params: arr };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "notIn": {
|
|
106
|
+
const arr = value as SQLQueryBindings[];
|
|
107
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
108
|
+
// Empty NOT IN clause: always true (no exclusions)
|
|
109
|
+
return { sql: "1 = 1", params: [] };
|
|
110
|
+
}
|
|
111
|
+
const placeholders = arr.map(() => "?").join(", ");
|
|
112
|
+
return { sql: `${column} NOT IN (${placeholders})`, params: arr };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case "isNull":
|
|
116
|
+
return { sql: `${column} IS NULL`, params: [] };
|
|
117
|
+
|
|
118
|
+
case "isNotNull":
|
|
119
|
+
return { sql: `${column} IS NOT NULL`, params: [] };
|
|
120
|
+
|
|
121
|
+
case "contains":
|
|
122
|
+
// LIKE '%value%' - escape special LIKE chars
|
|
123
|
+
return {
|
|
124
|
+
sql: `${column} LIKE ? ESCAPE '\\'`,
|
|
125
|
+
params: [`%${escapeLike(String(value))}%`],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
case "startsWith":
|
|
129
|
+
// LIKE 'value%'
|
|
130
|
+
return {
|
|
131
|
+
sql: `${column} LIKE ? ESCAPE '\\'`,
|
|
132
|
+
params: [`${escapeLike(String(value))}%`],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
case "endsWith":
|
|
136
|
+
// LIKE '%value'
|
|
137
|
+
return {
|
|
138
|
+
sql: `${column} LIKE ? ESCAPE '\\'`,
|
|
139
|
+
params: [`%${escapeLike(String(value))}`],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Escape special characters for LIKE patterns.
|
|
146
|
+
*
|
|
147
|
+
* SQLite LIKE special chars: % _
|
|
148
|
+
*/
|
|
149
|
+
function escapeLike(value: string): string {
|
|
150
|
+
return value.replace(/[%_]/g, (char) => `\\${char}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build a WHERE clause from a WhereClause object.
|
|
155
|
+
*
|
|
156
|
+
* @param meta - Collection metadata
|
|
157
|
+
* @param where - Where clause object mapping property names to conditions
|
|
158
|
+
* @returns WhereResult with SQL fragment and bind parameters
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* const { sql, params } = buildWhere(meta, {
|
|
163
|
+
* status: 'active',
|
|
164
|
+
* age: { op: 'gte', value: 18 },
|
|
165
|
+
* });
|
|
166
|
+
* // sql: "status = ? AND age >= ?"
|
|
167
|
+
* // params: ['active', 18]
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function buildWhere<T>(
|
|
171
|
+
meta: CollectionMeta,
|
|
172
|
+
where: WhereClause<T> | undefined,
|
|
173
|
+
): WhereResult {
|
|
174
|
+
if (!where || Object.keys(where).length === 0) {
|
|
175
|
+
return { sql: "", params: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const conditions: string[] = [];
|
|
179
|
+
const params: SQLQueryBindings[] = [];
|
|
180
|
+
|
|
181
|
+
for (const [property, whereValue] of Object.entries(where)) {
|
|
182
|
+
if (whereValue === undefined) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const column = getColumn(meta, property);
|
|
187
|
+
|
|
188
|
+
// Determine operator and value
|
|
189
|
+
let op: WhereOperator;
|
|
190
|
+
let value: unknown;
|
|
191
|
+
|
|
192
|
+
if (isWhereCondition(whereValue)) {
|
|
193
|
+
op = whereValue.op;
|
|
194
|
+
value = whereValue.value;
|
|
195
|
+
} else {
|
|
196
|
+
// Raw value treated as eq
|
|
197
|
+
op = "eq";
|
|
198
|
+
value = whereValue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = buildOperatorCondition(column, op, value);
|
|
202
|
+
conditions.push(result.sql);
|
|
203
|
+
params.push(...result.params);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (conditions.length === 0) {
|
|
207
|
+
return { sql: "", params: [] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
sql: conditions.join(" AND "),
|
|
212
|
+
params,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build an ORDER BY clause from an OrderByClause object.
|
|
218
|
+
*
|
|
219
|
+
* @param meta - Collection metadata
|
|
220
|
+
* @param orderBy - Order by clause object mapping property names to direction
|
|
221
|
+
* @returns SQL ORDER BY clause (without 'ORDER BY' keyword), empty string if no ordering
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```ts
|
|
225
|
+
* const sql = buildOrderBy(meta, { createdAt: 'desc', name: 'asc' });
|
|
226
|
+
* // "created_at DESC, name ASC"
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
export function buildOrderBy<T>(
|
|
230
|
+
meta: CollectionMeta,
|
|
231
|
+
orderBy: OrderByClause<T> | undefined,
|
|
232
|
+
): string {
|
|
233
|
+
if (!orderBy || Object.keys(orderBy).length === 0) {
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const clauses: string[] = [];
|
|
238
|
+
|
|
239
|
+
for (const [property, direction] of Object.entries(orderBy)) {
|
|
240
|
+
if (direction === undefined) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const column = getColumn(meta, property);
|
|
245
|
+
const dir = (direction as OrderDirection).toUpperCase();
|
|
246
|
+
|
|
247
|
+
// Validate direction to prevent injection
|
|
248
|
+
if (dir !== "ASC" && dir !== "DESC") {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Invalid order direction "${direction}" for property "${property}". ` +
|
|
251
|
+
`Use 'asc' or 'desc'.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
clauses.push(`${column} ${dir}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return clauses.join(", ");
|
|
259
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the collection persistence system.
|
|
3
|
+
*
|
|
4
|
+
* Collections are multi-row tables with explicit primary keys,
|
|
5
|
+
* as opposed to singleton @Persisted classes which use a key column.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Supported field types for persistence (same as singleton). */
|
|
9
|
+
export type FieldType = "string" | "number" | "boolean" | "date";
|
|
10
|
+
|
|
11
|
+
/** Metadata for a single persisted field in a collection. */
|
|
12
|
+
export interface CollectionFieldMeta {
|
|
13
|
+
/** Property name on the class. */
|
|
14
|
+
property: string;
|
|
15
|
+
/** SQLite column name (snake_case). */
|
|
16
|
+
column: string;
|
|
17
|
+
/** Field type for serialization. */
|
|
18
|
+
type: FieldType;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Index definition for a collection table. */
|
|
22
|
+
export interface IndexMeta {
|
|
23
|
+
/** Column names to index (snake_case). */
|
|
24
|
+
columns: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Metadata for a @PersistedCollection class. */
|
|
28
|
+
export interface CollectionMeta {
|
|
29
|
+
/** SQLite table name. */
|
|
30
|
+
table: string;
|
|
31
|
+
/** Property name of the @Id field. */
|
|
32
|
+
idProperty: string;
|
|
33
|
+
/** Column name of the @Id field. */
|
|
34
|
+
idColumn: string;
|
|
35
|
+
/** Type of the @Id field. */
|
|
36
|
+
idType: FieldType;
|
|
37
|
+
/** Map of property name to field metadata (excludes id field). */
|
|
38
|
+
fields: Map<string, CollectionFieldMeta>;
|
|
39
|
+
/** Index definitions. */
|
|
40
|
+
indices: IndexMeta[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* WeakMap storing collection metadata.
|
|
45
|
+
* Keyed by constructor function, holds CollectionMeta.
|
|
46
|
+
*/
|
|
47
|
+
export const collectionMeta = new WeakMap<object, CollectionMeta>();
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
// Query types
|
|
51
|
+
// --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Comparison operators for WHERE clauses.
|
|
55
|
+
*/
|
|
56
|
+
export type WhereOperator =
|
|
57
|
+
| "eq"
|
|
58
|
+
| "neq"
|
|
59
|
+
| "lt"
|
|
60
|
+
| "lte"
|
|
61
|
+
| "gt"
|
|
62
|
+
| "gte"
|
|
63
|
+
| "in"
|
|
64
|
+
| "notIn"
|
|
65
|
+
| "isNull"
|
|
66
|
+
| "isNotNull"
|
|
67
|
+
| "contains"
|
|
68
|
+
| "startsWith"
|
|
69
|
+
| "endsWith";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A single where condition with explicit operator.
|
|
73
|
+
*/
|
|
74
|
+
export interface WhereCondition<T> {
|
|
75
|
+
op: WhereOperator;
|
|
76
|
+
value: T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A where value can be:
|
|
81
|
+
* - A raw value (treated as eq)
|
|
82
|
+
* - A condition object with operator
|
|
83
|
+
*/
|
|
84
|
+
export type WhereValue<T> = T | WhereCondition<T>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where clause mapping field names to conditions.
|
|
88
|
+
* Keys are property names, values are WhereValue.
|
|
89
|
+
*/
|
|
90
|
+
export type WhereClause<T> = {
|
|
91
|
+
[K in keyof T]?: WhereValue<T[K]>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Order direction for sorting.
|
|
96
|
+
*/
|
|
97
|
+
export type OrderDirection = "asc" | "desc";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Order by clause - field name to direction.
|
|
101
|
+
*/
|
|
102
|
+
export type OrderByClause<T> = {
|
|
103
|
+
[K in keyof T]?: OrderDirection;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Options for find() queries.
|
|
108
|
+
*/
|
|
109
|
+
export interface FindOptions<T> {
|
|
110
|
+
/** Filter conditions. */
|
|
111
|
+
where?: WhereClause<T>;
|
|
112
|
+
/** Sort order. */
|
|
113
|
+
orderBy?: OrderByClause<T>;
|
|
114
|
+
/** Maximum number of results. */
|
|
115
|
+
limit?: number;
|
|
116
|
+
/** Number of results to skip. */
|
|
117
|
+
offset?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --------------------------------------------------------------------------
|
|
121
|
+
// CollectionEntity base class
|
|
122
|
+
// --------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Abstract base class for collection entities.
|
|
126
|
+
*
|
|
127
|
+
* Provides compile-time type safety to distinguish collection entities
|
|
128
|
+
* from singleton @Persisted classes. Subclasses must be decorated with
|
|
129
|
+
* @PersistedCollection.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* @PersistedCollection('users')
|
|
134
|
+
* class User extends CollectionEntity {
|
|
135
|
+
* @Id() id: string = '';
|
|
136
|
+
* @Field('string') name: string = '';
|
|
137
|
+
*
|
|
138
|
+
* async save(): Promise<void> {
|
|
139
|
+
* // Implemented by StateLoader binding
|
|
140
|
+
* }
|
|
141
|
+
*
|
|
142
|
+
* async delete(): Promise<void> {
|
|
143
|
+
* // Implemented by StateLoader binding
|
|
144
|
+
* }
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export abstract class CollectionEntity {
|
|
149
|
+
/**
|
|
150
|
+
* Persist this entity to the database.
|
|
151
|
+
* Implemented when the entity is bound to a StateLoader.
|
|
152
|
+
*/
|
|
153
|
+
abstract save(): Promise<void>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Delete this entity from the database.
|
|
157
|
+
* Implemented when the entity is bound to a StateLoader.
|
|
158
|
+
*/
|
|
159
|
+
abstract delete(): Promise<void>;
|
|
160
|
+
}
|
package/src/state/decorators.ts
CHANGED
|
@@ -25,9 +25,12 @@ import { classMeta, type FieldMeta, type FieldType } from "./types";
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Convert camelCase to snake_case.
|
|
28
|
+
* Handles leading uppercase (e.g., 'ID' -> 'id', not '_i_d').
|
|
28
29
|
*/
|
|
29
30
|
function toSnakeCase(str: string): string {
|
|
30
|
-
return str.replace(/[A-Z]/g, (letter) =>
|
|
31
|
+
return str.replace(/[A-Z]/g, (letter, index) =>
|
|
32
|
+
index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`,
|
|
33
|
+
);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** Options for the @Field decorator. */
|