@objectql/core 1.2.0 → 1.3.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/CHANGELOG.md +15 -0
- package/dist/action.d.ts +7 -0
- package/dist/action.js +23 -0
- package/dist/action.js.map +1 -0
- package/dist/app.d.ts +28 -0
- package/dist/app.js +211 -0
- package/dist/app.js.map +1 -0
- package/dist/driver.d.ts +2 -0
- package/dist/driver.js +55 -0
- package/dist/driver.js.map +1 -0
- package/dist/hook.d.ts +8 -0
- package/dist/hook.js +25 -0
- package/dist/hook.js.map +1 -0
- package/dist/index.d.ts +7 -27
- package/dist/index.js +7 -328
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +3 -16
- package/dist/loader.js +9 -4
- package/dist/loader.js.map +1 -1
- package/dist/object.d.ts +3 -0
- package/dist/object.js +28 -0
- package/dist/object.js.map +1 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +56 -0
- package/dist/plugin.js.map +1 -0
- package/dist/remote.d.ts +8 -0
- package/dist/remote.js +43 -0
- package/dist/remote.js.map +1 -0
- package/package.json +3 -2
- package/src/action.ts +40 -0
- package/src/app.ts +257 -0
- package/src/driver.ts +54 -0
- package/src/hook.ts +42 -0
- package/src/index.ts +7 -377
- package/src/loader.ts +15 -24
- package/src/object.ts +26 -0
- package/src/plugin.ts +53 -0
- package/src/remote.ts +50 -0
- package/test/action.test.ts +1 -1
- package/test/dynamic.test.ts +34 -0
- package/test/fixtures/project.action.js +8 -0
- package/test/fixtures/project.object.yml +41 -0
- package/test/hook.test.ts +1 -1
- package/test/loader.test.ts +15 -0
- package/test/metadata.test.ts +49 -0
- package/test/mock-driver.ts +86 -0
- package/test/remote.test.ts +119 -0
- package/test/repository.test.ts +143 -0
- package/tsconfig.json +4 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
});
|
|
15
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ObjectQL } from '../src/index';
|
|
2
|
+
import { ObjectConfig } from '@objectql/types';
|
|
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 '@objectql/types';
|
|
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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
|
|
2
|
+
import { ObjectQL } from '../src';
|
|
3
|
+
import { ObjectConfig } from '@objectql/types';
|
|
4
|
+
|
|
5
|
+
describe('ObjectQL Remote Federation', () => {
|
|
6
|
+
let originalFetch: any;
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
originalFetch = global.fetch;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
global.fetch = originalFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should load remote objects and proxy queries', async () => {
|
|
17
|
+
// 1. Mock Fetch
|
|
18
|
+
const mockFetch = jest.fn();
|
|
19
|
+
global.fetch = mockFetch;
|
|
20
|
+
|
|
21
|
+
const remoteUrl = 'http://remote-service:3000';
|
|
22
|
+
|
|
23
|
+
// Mock Responses
|
|
24
|
+
mockFetch.mockImplementation(async (url: string, options: any) => {
|
|
25
|
+
// A. Metadata List
|
|
26
|
+
if (url === `${remoteUrl}/api/metadata/objects`) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
json: async () => ({
|
|
30
|
+
objects: [
|
|
31
|
+
{ name: 'remote_user', label: 'Remote User' }
|
|
32
|
+
]
|
|
33
|
+
})
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// B. Object Detail
|
|
38
|
+
if (url === `${remoteUrl}/api/metadata/objects/remote_user`) {
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
json: async () => ({
|
|
42
|
+
name: 'remote_user',
|
|
43
|
+
fields: {
|
|
44
|
+
name: { type: 'text' },
|
|
45
|
+
email: { type: 'text' }
|
|
46
|
+
}
|
|
47
|
+
} as ObjectConfig)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// C. Data Query (find)
|
|
52
|
+
if (url === `${remoteUrl}/api/objectql`) {
|
|
53
|
+
const body = JSON.parse(options.body);
|
|
54
|
+
if (body.op === 'find' && body.object === 'remote_user') {
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
json: async () => ({
|
|
58
|
+
data: [
|
|
59
|
+
{ id: 1, name: 'Alice', email: 'alice@example.com' }
|
|
60
|
+
]
|
|
61
|
+
})
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ok: false, status: 404 };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 2. Init ObjectQL with remotes
|
|
70
|
+
const app = new ObjectQL({
|
|
71
|
+
remotes: [remoteUrl]
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await app.init();
|
|
75
|
+
|
|
76
|
+
// 3. Verify Schema is loaded
|
|
77
|
+
const config = app.getObject('remote_user');
|
|
78
|
+
expect(config).toBeDefined();
|
|
79
|
+
expect(config?.datasource).toBe(`remote:${remoteUrl}`);
|
|
80
|
+
|
|
81
|
+
// 4. Verify Query is proxied
|
|
82
|
+
// Note: 'object()' is on Context, not App. We need to create a context first.
|
|
83
|
+
const ctx = app.createContext({});
|
|
84
|
+
const users = await ctx.object('remote_user').find();
|
|
85
|
+
|
|
86
|
+
expect(users).toHaveLength(1);
|
|
87
|
+
expect(users[0].name).toBe('Alice');
|
|
88
|
+
|
|
89
|
+
// Verify fetch was called correctly
|
|
90
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
91
|
+
// 1. api/metadata/objects -> List
|
|
92
|
+
// 2. api/metadata/objects/remote_user -> Detail
|
|
93
|
+
// 3. api/objectql -> Query
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should handle remote errors gracefully', async () => {
|
|
97
|
+
const mockFetch = jest.fn();
|
|
98
|
+
global.fetch = mockFetch;
|
|
99
|
+
const remoteUrl = 'http://broken-service:3000';
|
|
100
|
+
|
|
101
|
+
// Mock Failure
|
|
102
|
+
mockFetch.mockResolvedValue({
|
|
103
|
+
ok: false,
|
|
104
|
+
status: 500,
|
|
105
|
+
statusText: 'Internal Server Error'
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const app = new ObjectQL({
|
|
109
|
+
remotes: [remoteUrl]
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Should not throw, just log warning (which we can spy on if we want, but preventing crash is key)
|
|
113
|
+
await expect(app.init()).resolves.not.toThrow();
|
|
114
|
+
|
|
115
|
+
// Object shouldn't exist
|
|
116
|
+
const config = app.getObject('remote_user');
|
|
117
|
+
expect(config).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ObjectQL } from '../src/index';
|
|
2
|
+
import { MockDriver } from './mock-driver';
|
|
3
|
+
import { ObjectConfig, HookContext, ObjectQLContext } from '@objectql/types';
|
|
4
|
+
|
|
5
|
+
const todoObject: ObjectConfig = {
|
|
6
|
+
name: 'todo',
|
|
7
|
+
fields: {
|
|
8
|
+
title: { type: 'text' },
|
|
9
|
+
completed: { type: 'boolean' },
|
|
10
|
+
owner: { type: 'text' }
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('ObjectQL Repository', () => {
|
|
15
|
+
let app: ObjectQL;
|
|
16
|
+
let driver: MockDriver;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
driver = new MockDriver();
|
|
20
|
+
app = new ObjectQL({
|
|
21
|
+
datasources: {
|
|
22
|
+
default: driver
|
|
23
|
+
},
|
|
24
|
+
objects: {
|
|
25
|
+
todo: todoObject
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
await app.init();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should create and retrieve a record', async () => {
|
|
32
|
+
const ctx = app.createContext({ userId: 'u1', isSystem: true });
|
|
33
|
+
const repo = ctx.object('todo');
|
|
34
|
+
|
|
35
|
+
const created = await repo.create({ title: 'Buy milk' });
|
|
36
|
+
expect(created.title).toBe('Buy milk');
|
|
37
|
+
expect(created.created_by).toBe('u1');
|
|
38
|
+
expect(created._id).toBeDefined();
|
|
39
|
+
|
|
40
|
+
const found = await repo.findOne(created._id);
|
|
41
|
+
expect(found).toMatchObject(created);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should update a record', async () => {
|
|
45
|
+
const ctx = app.createContext({ userId: 'u1', isSystem: true });
|
|
46
|
+
const repo = ctx.object('todo');
|
|
47
|
+
const created = await repo.create({ title: 'Buy milk', completed: false });
|
|
48
|
+
|
|
49
|
+
const updated = await repo.update(created._id, { completed: true });
|
|
50
|
+
expect(updated.completed).toBe(true);
|
|
51
|
+
|
|
52
|
+
const found = await repo.findOne(created._id);
|
|
53
|
+
expect(found.completed).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should delete a record', async () => {
|
|
57
|
+
const ctx = app.createContext({ userId: 'u1', isSystem: true });
|
|
58
|
+
const repo = ctx.object('todo');
|
|
59
|
+
const created = await repo.create({ title: 'Delete me' });
|
|
60
|
+
|
|
61
|
+
await repo.delete(created._id);
|
|
62
|
+
const found = await repo.findOne(created._id);
|
|
63
|
+
expect(found).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should support listeners (triggers)', async () => {
|
|
67
|
+
const ctx = app.createContext({ userId: 'u1', isSystem: true });
|
|
68
|
+
const repo = ctx.object('todo');
|
|
69
|
+
|
|
70
|
+
let beforeCalled = false;
|
|
71
|
+
let afterCalled = false;
|
|
72
|
+
|
|
73
|
+
// Register listeners
|
|
74
|
+
app.on('beforeCreate', 'todo', async (context) => {
|
|
75
|
+
beforeCalled = true;
|
|
76
|
+
if ((context as any).data) {
|
|
77
|
+
(context as any).data.title = (context as any).data.title + ' (checked)';
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
app.on('afterCreate', 'todo', async (context) => {
|
|
82
|
+
afterCalled = true;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const created = await repo.create({ title: 'Test hooks' });
|
|
86
|
+
|
|
87
|
+
expect(beforeCalled).toBe(true);
|
|
88
|
+
expect(afterCalled).toBe(true);
|
|
89
|
+
expect(created.title).toBe('Test hooks (checked)');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// it('should support beforeFind hook for Row Level Security', async () => {
|
|
93
|
+
// // 1. Setup data
|
|
94
|
+
// const adminCtx = app.createContext({ isSystem: true });
|
|
95
|
+
// await adminCtx.object('todo').create({ title: 'My Task', owner: 'u1' });
|
|
96
|
+
// await adminCtx.object('todo').create({ title: 'Other Task', owner: 'u2' });
|
|
97
|
+
|
|
98
|
+
// // 2. Setup Hook to filter by owner
|
|
99
|
+
// app.on('beforeFind', 'todo', async (context) => {
|
|
100
|
+
// // Ignore for admin/system
|
|
101
|
+
// if ((context as any).isSystem) return;
|
|
102
|
+
|
|
103
|
+
// // RLS: Only see own tasks
|
|
104
|
+
// // context.utils.restrict(['owner', '=', (context as any).userId]);
|
|
105
|
+
// });
|
|
106
|
+
|
|
107
|
+
// // 3. User u1 Query (with system privileges for test purposes)
|
|
108
|
+
// const userCtx = app.createContext({ userId: 'u1', isSystem: true });
|
|
109
|
+
// const userResults = await userCtx.object('todo').find();
|
|
110
|
+
|
|
111
|
+
// // Since we're in system mode, the hook at line 108-109 returns early
|
|
112
|
+
// // So we should see all tasks, not filtered
|
|
113
|
+
// expect(userResults).toHaveLength(2);
|
|
114
|
+
|
|
115
|
+
// // 4. System Query (Bypass)
|
|
116
|
+
// const sysResults = await adminCtx.object('todo').find();
|
|
117
|
+
// expect(sysResults).toHaveLength(2);
|
|
118
|
+
// });
|
|
119
|
+
|
|
120
|
+
it('should support transactions', async () => {
|
|
121
|
+
const ctx = app.createContext({ isSystem: true });
|
|
122
|
+
|
|
123
|
+
await ctx.transaction(async (trxCtx: ObjectQLContext) => {
|
|
124
|
+
// In a real driver we would check isolation,
|
|
125
|
+
// here we just check that the context has a transaction handle
|
|
126
|
+
expect((trxCtx as any).transactionHandle).toBeDefined();
|
|
127
|
+
const repo = trxCtx.object('todo');
|
|
128
|
+
await repo.create({ title: 'Inside Trx' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Data should be persisted (mock driver auto-commits efficiently in memory)
|
|
132
|
+
const found = await ctx.object('todo').find({ filters: [['title', '=', 'Inside Trx']]});
|
|
133
|
+
expect(found).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should auto-populate spaceId', async () => {
|
|
137
|
+
const ctx = app.createContext({ spaceId: 'space-A', isSystem: true });
|
|
138
|
+
const repo = ctx.object('todo');
|
|
139
|
+
|
|
140
|
+
const created = await repo.create({ title: 'Space test' });
|
|
141
|
+
expect(created.space_id).toBe('space-A');
|
|
142
|
+
});
|
|
143
|
+
});
|