@objectql/core 0.1.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/dist/driver.d.ts +17 -0
- package/dist/driver.js +3 -0
- package/dist/driver.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +115 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +143 -0
- package/dist/loader.js.map +1 -0
- package/dist/metadata.d.ts +102 -0
- package/dist/metadata.js +3 -0
- package/dist/metadata.js.map +1 -0
- package/dist/query.d.ts +10 -0
- package/dist/query.js +3 -0
- package/dist/query.js.map +1 -0
- package/dist/repository.d.ts +26 -0
- package/dist/repository.js +209 -0
- package/dist/repository.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +5 -0
- package/package.json +14 -0
- package/src/driver.ts +24 -0
- package/src/index.ts +107 -0
- package/src/loader.ts +120 -0
- package/src/metadata.ts +141 -0
- package/src/query.ts +11 -0
- package/src/repository.ts +232 -0
- package/src/types.ts +107 -0
- package/test/fixtures/project.object.yml +41 -0
- package/test/loader.test.ts +14 -0
- package/test/metadata.test.ts +49 -0
- package/test/mock-driver.ts +86 -0
- package/test/repository.test.ts +150 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Represents the supported field data types in the ObjectQL schema.
|
|
4
|
+
* These types determine how data is stored, validated, and rendered.
|
|
5
|
+
*
|
|
6
|
+
* - `text`: Simple string.
|
|
7
|
+
* - `textarea`: Long string.
|
|
8
|
+
* - `select`: Choice from a list.
|
|
9
|
+
* - `lookup`: Relationship to another object.
|
|
10
|
+
*/
|
|
11
|
+
export type FieldType =
|
|
12
|
+
| 'text'
|
|
13
|
+
| 'textarea'
|
|
14
|
+
| 'html'
|
|
15
|
+
| 'select'
|
|
16
|
+
| 'multiselect'
|
|
17
|
+
| 'date'
|
|
18
|
+
| 'datetime'
|
|
19
|
+
| 'number'
|
|
20
|
+
| 'currency'
|
|
21
|
+
| 'boolean'
|
|
22
|
+
| 'lookup'
|
|
23
|
+
| 'master_detail'
|
|
24
|
+
| 'password'
|
|
25
|
+
| 'object'
|
|
26
|
+
| 'grid';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Defines a single option for select/multiselect fields.
|
|
30
|
+
*/
|
|
31
|
+
export interface FieldOption {
|
|
32
|
+
/** The display label for the option. */
|
|
33
|
+
label: string;
|
|
34
|
+
/** The actual value stored in the database. */
|
|
35
|
+
value: string | number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Configuration for a single field on an object.
|
|
40
|
+
* This defines the schema, validation rules, and UI hints for the attribute.
|
|
41
|
+
*/
|
|
42
|
+
export interface FieldConfig {
|
|
43
|
+
/**
|
|
44
|
+
* The unique API name of the field.
|
|
45
|
+
* If defined within an object map, this is often automatically populated from the key.
|
|
46
|
+
*/
|
|
47
|
+
name?: string;
|
|
48
|
+
|
|
49
|
+
/** The human-readable label used in UIs. */
|
|
50
|
+
label?: string;
|
|
51
|
+
|
|
52
|
+
/** The data type of the field. */
|
|
53
|
+
type: FieldType;
|
|
54
|
+
|
|
55
|
+
/** Whether the field is mandatory. Defaults to false. */
|
|
56
|
+
required?: boolean;
|
|
57
|
+
|
|
58
|
+
/** The default value if not provided during creation. */
|
|
59
|
+
defaultValue?: any;
|
|
60
|
+
|
|
61
|
+
// String options
|
|
62
|
+
/**
|
|
63
|
+
* Options available for `select` or `multiselect` types.
|
|
64
|
+
* Can be an array of strings or {@link FieldOption} objects.
|
|
65
|
+
*/
|
|
66
|
+
options?: FieldOption[] | string[];
|
|
67
|
+
|
|
68
|
+
// Number options
|
|
69
|
+
/** Number of decimal places for `currency` types (e.g., 2). */
|
|
70
|
+
scale?: number;
|
|
71
|
+
/** Total number of digits for `number` types. */
|
|
72
|
+
precision?: number;
|
|
73
|
+
|
|
74
|
+
// Relationship properties
|
|
75
|
+
/**
|
|
76
|
+
* The API name of the target object.
|
|
77
|
+
* Required when type is `lookup` or `master_detail`.
|
|
78
|
+
*/
|
|
79
|
+
reference_to?: string;
|
|
80
|
+
|
|
81
|
+
// UI properties (kept for compatibility, though ObjectQL is a query engine)
|
|
82
|
+
/** Implementation hint: Whether this field should be indexed for search. */
|
|
83
|
+
searchable?: boolean;
|
|
84
|
+
/** Implementation hint: Whether this field is sortable in lists. */
|
|
85
|
+
sortable?: boolean;
|
|
86
|
+
/** Implementation hint: Whether to create a database index for this column. */
|
|
87
|
+
index?: boolean;
|
|
88
|
+
|
|
89
|
+
// Other properties
|
|
90
|
+
/** Description for documentation purposes. */
|
|
91
|
+
description?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Configuration for a custom action (RPC).
|
|
96
|
+
*/
|
|
97
|
+
export interface ActionConfig {
|
|
98
|
+
label?: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
/** Output/Result type definition. */
|
|
101
|
+
result?: {
|
|
102
|
+
type: FieldType;
|
|
103
|
+
};
|
|
104
|
+
/** Input parameters schema. */
|
|
105
|
+
params?: Record<string, FieldConfig>;
|
|
106
|
+
/** Implementation of the action. */
|
|
107
|
+
handler?: (ctx: any, params: any) => Promise<any>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
import { HookFunction } from './types';
|
|
111
|
+
|
|
112
|
+
export interface ObjectListeners {
|
|
113
|
+
beforeCreate?: HookFunction;
|
|
114
|
+
afterCreate?: HookFunction;
|
|
115
|
+
beforeUpdate?: HookFunction;
|
|
116
|
+
afterUpdate?: HookFunction;
|
|
117
|
+
beforeDelete?: HookFunction;
|
|
118
|
+
afterDelete?: HookFunction;
|
|
119
|
+
beforeFind?: HookFunction;
|
|
120
|
+
afterFind?: HookFunction;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Configuration for a business object (Entity).
|
|
125
|
+
* Analogous to a Database Table or MongoDB Collection.
|
|
126
|
+
*/
|
|
127
|
+
export interface ObjectConfig {
|
|
128
|
+
name: string;
|
|
129
|
+
datasource?: string; // The name of the datasource to use
|
|
130
|
+
label?: string;
|
|
131
|
+
icon?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
|
|
134
|
+
fields: Record<string, FieldConfig>;
|
|
135
|
+
|
|
136
|
+
/** Custom Actions (RPC) defined on this object. */
|
|
137
|
+
actions?: Record<string, ActionConfig>;
|
|
138
|
+
|
|
139
|
+
/** Lifecycle hooks. */
|
|
140
|
+
listeners?: ObjectListeners;
|
|
141
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type FilterCriterion = [string, string, any];
|
|
2
|
+
export type FilterExpression = FilterCriterion | 'and' | 'or' | FilterExpression[];
|
|
3
|
+
|
|
4
|
+
export interface UnifiedQuery {
|
|
5
|
+
fields?: string[];
|
|
6
|
+
filters?: FilterExpression[];
|
|
7
|
+
sort?: [string, 'asc' | 'desc'][];
|
|
8
|
+
skip?: number;
|
|
9
|
+
limit?: number;
|
|
10
|
+
expand?: Record<string, UnifiedQuery>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { ObjectQLContext, IObjectQL, HookContext, HookFunction } from './types';
|
|
2
|
+
import { ObjectConfig, FieldConfig } from './metadata';
|
|
3
|
+
import { Driver } from './driver';
|
|
4
|
+
import { UnifiedQuery, FilterCriterion } from './query';
|
|
5
|
+
|
|
6
|
+
export class ObjectRepository {
|
|
7
|
+
constructor(
|
|
8
|
+
private objectName: string,
|
|
9
|
+
private context: ObjectQLContext,
|
|
10
|
+
private app: IObjectQL
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
private getDriver(): Driver {
|
|
14
|
+
const obj = this.getSchema();
|
|
15
|
+
const datasourceName = obj.datasource || 'default';
|
|
16
|
+
return this.app.datasource(datasourceName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private getOptions(extra: any = {}) {
|
|
20
|
+
return {
|
|
21
|
+
transaction: this.context.transactionHandle,
|
|
22
|
+
...extra
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getSchema(): ObjectConfig {
|
|
27
|
+
const obj = this.app.getObject(this.objectName);
|
|
28
|
+
if (!obj) {
|
|
29
|
+
throw new Error(`Object '${this.objectName}' not found`);
|
|
30
|
+
}
|
|
31
|
+
return obj;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// === Hook Execution Logic ===
|
|
35
|
+
private async executeHook(
|
|
36
|
+
hookName: keyof import('./metadata').ObjectListeners,
|
|
37
|
+
op: HookContext['op'],
|
|
38
|
+
dataOrQuery: any
|
|
39
|
+
) {
|
|
40
|
+
if (this.context.ignoreTriggers) return;
|
|
41
|
+
|
|
42
|
+
const obj = this.getSchema();
|
|
43
|
+
if (!obj.listeners || !obj.listeners[hookName]) return;
|
|
44
|
+
|
|
45
|
+
const hookFn = obj.listeners[hookName] as HookFunction;
|
|
46
|
+
|
|
47
|
+
// Construct HookContext
|
|
48
|
+
const hookContext: HookContext = {
|
|
49
|
+
ctx: this.context,
|
|
50
|
+
entity: this.objectName,
|
|
51
|
+
op: op,
|
|
52
|
+
utils: {
|
|
53
|
+
restrict: (criterion: FilterCriterion) => {
|
|
54
|
+
if (op !== 'find' && op !== 'count') {
|
|
55
|
+
throw new Error('utils.restrict is only available in query operations');
|
|
56
|
+
}
|
|
57
|
+
const query = dataOrQuery as UnifiedQuery;
|
|
58
|
+
if (!query.filters) {
|
|
59
|
+
query.filters = [criterion];
|
|
60
|
+
} else {
|
|
61
|
+
// Enclose existing filters in implicit AND group by array structure logic or explicit 'and'
|
|
62
|
+
// Implementation depends on how driver parses.
|
|
63
|
+
// Safe approach: filters = [ [criterion], 'and', [existing] ] or similar.
|
|
64
|
+
// For simplicity assuming array of terms means AND:
|
|
65
|
+
query.filters.push(criterion);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
getPreviousDoc: async () => {
|
|
70
|
+
// For update/delete, we might need the ID to find the doc.
|
|
71
|
+
// If doc has ID, use it.
|
|
72
|
+
// This is simplistic; usually 'update' takes 'id', we need to capture it from arguments.
|
|
73
|
+
if (op === 'create') return undefined;
|
|
74
|
+
if (dataOrQuery._id || dataOrQuery.id) {
|
|
75
|
+
return this.findOne(dataOrQuery._id || dataOrQuery.id);
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (op === 'find' || op === 'count' || op === 'aggregate') {
|
|
82
|
+
hookContext.query = dataOrQuery;
|
|
83
|
+
} else {
|
|
84
|
+
hookContext.doc = dataOrQuery;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pass ID manually if needed or attach to doc?
|
|
88
|
+
// For strictness, getPreviousDoc needs the ID passed to the operation.
|
|
89
|
+
// We'll rely on "doc" having the data being processed.
|
|
90
|
+
|
|
91
|
+
await hookFn(hookContext);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async find(query: UnifiedQuery = {}): Promise<any[]> {
|
|
95
|
+
// Hooks: beforeFind
|
|
96
|
+
await this.executeHook('beforeFind', 'find', query);
|
|
97
|
+
|
|
98
|
+
// TODO: Apply basic filters like spaceId (could be done via a default generic hook too)
|
|
99
|
+
const results = await this.getDriver().find(this.objectName, query, this.getOptions());
|
|
100
|
+
|
|
101
|
+
// Hooks: afterFind
|
|
102
|
+
// Not implemented in spec fully iterate results? usually for single doc or metadata
|
|
103
|
+
// For performance, afterFind on list is rare or costly.
|
|
104
|
+
if (this.getSchema().listeners?.afterFind && !this.context.ignoreTriggers) {
|
|
105
|
+
const hookFn = this.getSchema().listeners!.afterFind!;
|
|
106
|
+
// Executing per result or once? Spec says "HookContext" has "doc".
|
|
107
|
+
// If finding list, might not match signature.
|
|
108
|
+
// Implemented per-item for now (caution: performance).
|
|
109
|
+
/*
|
|
110
|
+
for (const item of results) {
|
|
111
|
+
await this.executeHookForDoc('afterFind', 'find', item);
|
|
112
|
+
}
|
|
113
|
+
*/
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async findOne(idOrQuery: string | number | UnifiedQuery): Promise<any> {
|
|
119
|
+
if (typeof idOrQuery === 'string' || typeof idOrQuery === 'number') {
|
|
120
|
+
// Convert ID lookup to standard query to reuse 'find' hooks?
|
|
121
|
+
// Or treat as specific op.
|
|
122
|
+
// Let's rely on simple driver call but maybe wrap in object for hook consistency if needed.
|
|
123
|
+
// For now, simple implementation:
|
|
124
|
+
return this.getDriver().findOne(this.objectName, idOrQuery, undefined, this.getOptions());
|
|
125
|
+
} else {
|
|
126
|
+
const results = await this.find(idOrQuery);
|
|
127
|
+
return results[0] || null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async count(filters: any): Promise<number> {
|
|
132
|
+
// Can wrap filters in a query object for hook
|
|
133
|
+
const query: UnifiedQuery = { filters };
|
|
134
|
+
await this.executeHook('beforeFind', 'count', query); // Reusing beforeFind logic often?
|
|
135
|
+
return this.getDriver().count(this.objectName, query.filters, this.getOptions());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async create(doc: any): Promise<any> {
|
|
139
|
+
const obj = this.getSchema();
|
|
140
|
+
if (this.context.userId) doc.created_by = this.context.userId;
|
|
141
|
+
if (this.context.spaceId) doc.space_id = this.context.spaceId;
|
|
142
|
+
|
|
143
|
+
await this.executeHook('beforeCreate', 'create', doc);
|
|
144
|
+
|
|
145
|
+
const result = await this.getDriver().create(this.objectName, doc, this.getOptions());
|
|
146
|
+
|
|
147
|
+
await this.executeHook('afterCreate', 'create', result);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async update(id: string | number, doc: any, options?: any): Promise<any> {
|
|
152
|
+
// Attach ID to doc for hook context to know which record
|
|
153
|
+
const docWithId = { ...doc, _id: id, id: id };
|
|
154
|
+
|
|
155
|
+
await this.executeHook('beforeUpdate', 'update', docWithId);
|
|
156
|
+
|
|
157
|
+
// Remove ID before sending to driver if driver doesn't like it in $set
|
|
158
|
+
const { _id, id: _id2, ...cleanDoc } = docWithId;
|
|
159
|
+
|
|
160
|
+
const result = await this.getDriver().update(this.objectName, id, cleanDoc, this.getOptions(options));
|
|
161
|
+
|
|
162
|
+
// Result might be count or doc depending on driver.
|
|
163
|
+
// If we want the updated doc for afterUpdate, we might need to fetch it if driver defaults to count.
|
|
164
|
+
// Assuming result is the doc or we just pass the patch.
|
|
165
|
+
await this.executeHook('afterUpdate', 'update', docWithId);
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async delete(id: string | number): Promise<any> {
|
|
170
|
+
const docWithId = { _id: id, id: id };
|
|
171
|
+
await this.executeHook('beforeDelete', 'delete', docWithId);
|
|
172
|
+
|
|
173
|
+
const result = await this.getDriver().delete(this.objectName, id, this.getOptions());
|
|
174
|
+
|
|
175
|
+
await this.executeHook('afterDelete', 'delete', docWithId);
|
|
176
|
+
return result;
|
|
177
|
+
} async aggregate(query: any): Promise<any> {
|
|
178
|
+
const driver = this.getDriver();
|
|
179
|
+
if (!driver.aggregate) throw new Error("Driver does not support aggregate");
|
|
180
|
+
return driver.aggregate(this.objectName, query, this.getOptions());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async distinct(field: string, filters?: any): Promise<any[]> {
|
|
184
|
+
const driver = this.getDriver();
|
|
185
|
+
if (!driver.distinct) throw new Error("Driver does not support distinct");
|
|
186
|
+
return driver.distinct(this.objectName, field, filters, this.getOptions());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async findOneAndUpdate(filters: any, update: any, options?: any): Promise<any> {
|
|
190
|
+
const driver = this.getDriver();
|
|
191
|
+
if (!driver.findOneAndUpdate) throw new Error("Driver does not support findOneAndUpdate");
|
|
192
|
+
return driver.findOneAndUpdate(this.objectName, filters, update, this.getOptions(options));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async createMany(data: any[]): Promise<any> {
|
|
196
|
+
// TODO: Triggers per record?
|
|
197
|
+
const driver = this.getDriver();
|
|
198
|
+
if (!driver.createMany) {
|
|
199
|
+
// Fallback
|
|
200
|
+
const results = [];
|
|
201
|
+
for (const item of data) {
|
|
202
|
+
results.push(await this.create(item));
|
|
203
|
+
}
|
|
204
|
+
return results;
|
|
205
|
+
}
|
|
206
|
+
return driver.createMany(this.objectName, data, this.getOptions());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async updateMany(filters: any, data: any): Promise<any> {
|
|
210
|
+
const driver = this.getDriver();
|
|
211
|
+
if (!driver.updateMany) throw new Error("Driver does not support updateMany");
|
|
212
|
+
return driver.updateMany(this.objectName, filters, data, this.getOptions());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async deleteMany(filters: any): Promise<any> {
|
|
216
|
+
const driver = this.getDriver();
|
|
217
|
+
if (!driver.deleteMany) throw new Error("Driver does not support deleteMany");
|
|
218
|
+
return driver.deleteMany(this.objectName, filters, this.getOptions());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async call(actionName: string, params: any): Promise<any> {
|
|
222
|
+
const obj = this.getSchema();
|
|
223
|
+
const action = obj.actions?.[actionName];
|
|
224
|
+
if (!action) {
|
|
225
|
+
throw new Error(`Action '${actionName}' not found on object '${this.objectName}'`);
|
|
226
|
+
}
|
|
227
|
+
if (action.handler) {
|
|
228
|
+
return action.handler(this.context, params);
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Action '${actionName}' has no handler`);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ObjectRepository } from "./repository";
|
|
2
|
+
import { ObjectConfig } from "./metadata";
|
|
3
|
+
import { Driver } from "./driver";
|
|
4
|
+
import { UnifiedQuery, FilterCriterion } from "./query";
|
|
5
|
+
|
|
6
|
+
export { ObjectConfig } from "./metadata";
|
|
7
|
+
|
|
8
|
+
export interface ObjectQLConfig {
|
|
9
|
+
datasources: Record<string, Driver>;
|
|
10
|
+
objects?: Record<string, ObjectConfig>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IObjectQL {
|
|
14
|
+
getObject(name: string): ObjectConfig | undefined;
|
|
15
|
+
getConfigs(): Record<string, ObjectConfig>;
|
|
16
|
+
datasource(name: string): Driver;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HookContext<T = any> {
|
|
20
|
+
// === 1. The Session Context ===
|
|
21
|
+
// Automatically propagates userId, spaceId, and Transaction.
|
|
22
|
+
ctx: ObjectQLContext;
|
|
23
|
+
|
|
24
|
+
// === 2. Operational Info ===
|
|
25
|
+
entity: string;
|
|
26
|
+
op: 'find' | 'create' | 'update' | 'delete' | 'count' | 'aggregate';
|
|
27
|
+
|
|
28
|
+
// === 3. Data Payload (Mutable) ===
|
|
29
|
+
// - In beforeCreate/Update: The data to be written.
|
|
30
|
+
// - In afterCreate/Update: The result record returned from DB.
|
|
31
|
+
doc?: T;
|
|
32
|
+
|
|
33
|
+
// === 4. Query Context (Mutable, for 'find' only) ===
|
|
34
|
+
// Complies strictly with the UnifiedQuery JSON-DSL (AST).
|
|
35
|
+
// Developers can modify 'fields', 'sort', or wrap 'filters'.
|
|
36
|
+
query?: UnifiedQuery;
|
|
37
|
+
|
|
38
|
+
// === 5. Helpers ===
|
|
39
|
+
getPreviousDoc: () => Promise<T | undefined>;
|
|
40
|
+
|
|
41
|
+
// AST Manipulation Utilities
|
|
42
|
+
utils: {
|
|
43
|
+
/**
|
|
44
|
+
* Safely injects a new filter criterion into the existing AST.
|
|
45
|
+
* It wraps existing filters in a new group to preserve operator precedence.
|
|
46
|
+
* * Logic: (Existing_Filters) AND (New_Filter)
|
|
47
|
+
*/
|
|
48
|
+
restrict: (criterion: FilterCriterion) => void;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type HookFunction = (context: HookContext) => Promise<void>;
|
|
53
|
+
|
|
54
|
+
export interface ObjectQLContext {
|
|
55
|
+
// === Identity & Isolation ===
|
|
56
|
+
userId?: string; // Current User ID
|
|
57
|
+
spaceId?: string; // Multi-tenancy Isolation (Organization ID)
|
|
58
|
+
roles: string[]; // RBAC Roles
|
|
59
|
+
|
|
60
|
+
// === Execution Flags ===
|
|
61
|
+
/**
|
|
62
|
+
* Sudo Mode / System Bypass.
|
|
63
|
+
* - true: Bypasses all permission checks (CRUD, Field Level Security, Record Level Security).
|
|
64
|
+
* - false/undefined: Enforces all permission checks based on 'roles'.
|
|
65
|
+
*/
|
|
66
|
+
isSystem?: boolean;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Trigger Control.
|
|
70
|
+
* - true: Skips all lifecycle hooks (beforeCreate, afterUpdate, etc.).
|
|
71
|
+
* - Useful for bulk data imports or raw data correction to prevent side effects.
|
|
72
|
+
* - Requires 'isSystem: true' (Security Safeguard).
|
|
73
|
+
*/
|
|
74
|
+
ignoreTriggers?: boolean;
|
|
75
|
+
|
|
76
|
+
// === Data Entry Point ===
|
|
77
|
+
/**
|
|
78
|
+
* Returns a repository proxy bound to this context.
|
|
79
|
+
* All operations performed via this proxy inherit userId, spaceId, and transaction.
|
|
80
|
+
*/
|
|
81
|
+
object(entityName: string): ObjectRepository;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute a function within a transaction.
|
|
85
|
+
* The callback receives a new context 'trxCtx' which inherits userId and spaceId from this context.
|
|
86
|
+
*/
|
|
87
|
+
transaction(callback: (trxCtx: ObjectQLContext) => Promise<any>): Promise<any>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns a new context with system privileges (isSystem: true).
|
|
91
|
+
* It shares the same transaction scope as the current context.
|
|
92
|
+
*/
|
|
93
|
+
sudo(): ObjectQLContext;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Internal: Driver-specific transaction handle.
|
|
97
|
+
*/
|
|
98
|
+
transactionHandle?: any;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ObjectQLContextOptions {
|
|
102
|
+
userId?: string;
|
|
103
|
+
spaceId?: string;
|
|
104
|
+
roles?: string[];
|
|
105
|
+
isSystem?: boolean;
|
|
106
|
+
ignoreTriggers?: boolean;
|
|
107
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: project
|
|
2
|
+
label: Project
|
|
3
|
+
icon: standard:case
|
|
4
|
+
enable_search: true
|
|
5
|
+
fields:
|
|
6
|
+
name:
|
|
7
|
+
label: Project Name
|
|
8
|
+
type: text
|
|
9
|
+
required: true
|
|
10
|
+
searchable: true
|
|
11
|
+
index: true
|
|
12
|
+
|
|
13
|
+
status:
|
|
14
|
+
label: Status
|
|
15
|
+
type: select
|
|
16
|
+
options:
|
|
17
|
+
- label: Planned
|
|
18
|
+
value: planned
|
|
19
|
+
- label: In Progress
|
|
20
|
+
value: in_progress
|
|
21
|
+
- label: Completed
|
|
22
|
+
value: completed
|
|
23
|
+
defaultValue: planned
|
|
24
|
+
|
|
25
|
+
start_date:
|
|
26
|
+
label: Start Date
|
|
27
|
+
type: date
|
|
28
|
+
|
|
29
|
+
owner:
|
|
30
|
+
label: Project Manager
|
|
31
|
+
type: lookup
|
|
32
|
+
reference_to: users
|
|
33
|
+
|
|
34
|
+
budget:
|
|
35
|
+
label: Total Budget
|
|
36
|
+
type: currency
|
|
37
|
+
scale: 2
|
|
38
|
+
|
|
39
|
+
description:
|
|
40
|
+
label: Description
|
|
41
|
+
type: textarea
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadObjectConfigs } from '../src/loader';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
describe('Loader', () => {
|
|
5
|
+
it('should load object configs from directory', () => {
|
|
6
|
+
const fixturesDir = path.join(__dirname, 'fixtures');
|
|
7
|
+
const configs = loadObjectConfigs(fixturesDir);
|
|
8
|
+
expect(configs).toBeDefined();
|
|
9
|
+
expect(configs['project']).toBeDefined();
|
|
10
|
+
expect(configs['project'].name).toBe('project');
|
|
11
|
+
expect(configs['project'].fields).toBeDefined();
|
|
12
|
+
expect(configs['project'].fields.name).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ObjectQL } from '../src/index';
|
|
2
|
+
import { ObjectConfig } from '../src/metadata';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
describe('Metadata Loading', () => {
|
|
8
|
+
|
|
9
|
+
it('should load definitions from .object.yml file', () => {
|
|
10
|
+
// 1. Read YAML file
|
|
11
|
+
const yamlPath = path.join(__dirname, 'fixtures', 'project.object.yml');
|
|
12
|
+
const fileContents = fs.readFileSync(yamlPath, 'utf8');
|
|
13
|
+
|
|
14
|
+
// 2. Parse YAML
|
|
15
|
+
const objectDef = yaml.load(fileContents) as ObjectConfig;
|
|
16
|
+
|
|
17
|
+
// 3. Verify Structure
|
|
18
|
+
expect(objectDef.name).toBe('project');
|
|
19
|
+
expect(objectDef.fields.name.type).toBe('text');
|
|
20
|
+
expect(objectDef.fields.status.options).toHaveLength(3);
|
|
21
|
+
expect(objectDef.fields.budget.type).toBe('currency');
|
|
22
|
+
expect(objectDef.fields.owner.reference_to).toBe('users');
|
|
23
|
+
|
|
24
|
+
// 4. Register with ObjectQL
|
|
25
|
+
const app = new ObjectQL({
|
|
26
|
+
datasources: {}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.registerObject(objectDef);
|
|
30
|
+
|
|
31
|
+
// 5. Verify Registration
|
|
32
|
+
const retrieved = app.getObject('project');
|
|
33
|
+
expect(retrieved).toBeDefined();
|
|
34
|
+
expect(retrieved?.label).toBe('Project');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should validate required properties (manual validation simulation)', () => {
|
|
38
|
+
const yamlPath = path.join(__dirname, 'fixtures', 'project.object.yml');
|
|
39
|
+
const fileContents = fs.readFileSync(yamlPath, 'utf8');
|
|
40
|
+
const objectDef = yaml.load(fileContents) as ObjectConfig;
|
|
41
|
+
|
|
42
|
+
function validateObject(obj: ObjectConfig) {
|
|
43
|
+
if (!obj.name) throw new Error('Object name is required');
|
|
44
|
+
if (!obj.fields) throw new Error('Object fields are required');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(() => validateObject(objectDef)).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Driver } from '../src/driver';
|
|
2
|
+
|
|
3
|
+
export class MockDriver implements Driver {
|
|
4
|
+
private data: Record<string, any[]> = {};
|
|
5
|
+
private transactions: Set<any> = new Set();
|
|
6
|
+
|
|
7
|
+
constructor() {}
|
|
8
|
+
|
|
9
|
+
private getData(objectName: string) {
|
|
10
|
+
if (!this.data[objectName]) {
|
|
11
|
+
this.data[objectName] = [];
|
|
12
|
+
}
|
|
13
|
+
return this.data[objectName];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async find(objectName: string, query: any, options?: any): Promise<any[]> {
|
|
17
|
+
const items = this.getData(objectName);
|
|
18
|
+
// Very basic filter implementation for testing
|
|
19
|
+
if (query.filters) {
|
|
20
|
+
return items.filter(item => {
|
|
21
|
+
// Assuming simple filter: [['field', '=', 'value']]
|
|
22
|
+
const filter = query.filters[0];
|
|
23
|
+
if (filter && Array.isArray(filter) && filter[1] === '=') {
|
|
24
|
+
return item[filter[0]] === filter[2];
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return items;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async findOne(objectName: string, id: string | number, query?: any, options?: any): Promise<any> {
|
|
33
|
+
const items = this.getData(objectName);
|
|
34
|
+
return items.find((item: any) => item._id === id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async create(objectName: string, data: any, options?: any): Promise<any> {
|
|
38
|
+
const items = this.getData(objectName);
|
|
39
|
+
const newItem = {
|
|
40
|
+
...data,
|
|
41
|
+
_id: data._id || `id-${Date.now()}-${Math.random()}`
|
|
42
|
+
};
|
|
43
|
+
items.push(newItem);
|
|
44
|
+
return newItem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async update(objectName: string, id: string | number, data: any, options?: any): Promise<any> {
|
|
48
|
+
const items = this.getData(objectName);
|
|
49
|
+
const index = items.findIndex((item: any) => item._id === id);
|
|
50
|
+
if (index > -1) {
|
|
51
|
+
items[index] = { ...items[index], ...data };
|
|
52
|
+
return items[index];
|
|
53
|
+
}
|
|
54
|
+
throw new Error('Not found');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async delete(objectName: string, id: string | number, options?: any): Promise<any> {
|
|
58
|
+
const items = this.getData(objectName);
|
|
59
|
+
const index = items.findIndex((item: any) => item._id === id);
|
|
60
|
+
if (index > -1) {
|
|
61
|
+
items.splice(index, 1);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async count(objectName: string, filters: any, options?: any): Promise<number> {
|
|
68
|
+
return (await this.find(objectName, { filters }, options)).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async beginTransaction(): Promise<any> {
|
|
72
|
+
const trx = { id: Date.now() };
|
|
73
|
+
this.transactions.add(trx);
|
|
74
|
+
return trx;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async commitTransaction(trx: any): Promise<void> {
|
|
78
|
+
if (!this.transactions.has(trx)) throw new Error('Invalid transaction');
|
|
79
|
+
this.transactions.delete(trx);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async rollbackTransaction(trx: any): Promise<void> {
|
|
83
|
+
if (!this.transactions.has(trx)) throw new Error('Invalid transaction');
|
|
84
|
+
this.transactions.delete(trx);
|
|
85
|
+
}
|
|
86
|
+
}
|