Skip to content

Commit b0ffd20

Browse files
Copilothotlong
andcommitted
feat(objectql): add utility functions, introspection types, and kernel factory
Add upstream support for functionality from downstream @objectql/core: - toTitleCase() and convertIntrospectedSchemaToObjects() utilities - IntrospectedSchema/Table/Column/ForeignKey types for database introspection - createObjectQLKernel() factory and ObjectQLKernelOptions type - 22 new tests for utility functions Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 049e505 commit b0ffd20

4 files changed

Lines changed: 500 additions & 0 deletions

File tree

packages/objectql/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,19 @@ export { MetadataFacade } from './metadata-facade.js';
2323

2424
// Export Plugin Shim
2525
export { ObjectQLPlugin } from './plugin.js';
26+
27+
// Export Kernel Factory
28+
export { createObjectQLKernel } from './kernel-factory.js';
29+
export type { ObjectQLKernelOptions } from './kernel-factory.js';
30+
31+
// Export Utilities
32+
export {
33+
toTitleCase,
34+
convertIntrospectedSchemaToObjects,
35+
} from './util.js';
36+
export type {
37+
IntrospectedColumn,
38+
IntrospectedForeignKey,
39+
IntrospectedTable,
40+
IntrospectedSchema,
41+
} from './util.js';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectKernel } from '@objectstack/core';
4+
import { ObjectQLPlugin } from './plugin.js';
5+
import type { Plugin } from '@objectstack/core';
6+
7+
/**
8+
* Options for creating an ObjectQL Kernel.
9+
*/
10+
export interface ObjectQLKernelOptions {
11+
/**
12+
* Additional plugins to register with the kernel.
13+
*/
14+
plugins?: Plugin[];
15+
}
16+
17+
/**
18+
* Convenience factory for creating an ObjectQL-ready kernel.
19+
*
20+
* Creates an ObjectKernel pre-configured with the ObjectQLPlugin
21+
* (data engine, schema registry, protocol implementation) plus any
22+
* additional plugins provided.
23+
*
24+
* @example
25+
* ```typescript
26+
* import { createObjectQLKernel } from '@objectstack/objectql';
27+
*
28+
* const kernel = createObjectQLKernel({
29+
* plugins: [myDriverPlugin, myAuthPlugin],
30+
* });
31+
* await kernel.bootstrap();
32+
* ```
33+
*/
34+
export function createObjectQLKernel(options: ObjectQLKernelOptions = {}): ObjectKernel {
35+
return new (ObjectKernel as any)([
36+
new ObjectQLPlugin(),
37+
...(options.plugins || []),
38+
]);
39+
}

packages/objectql/src/util.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
toTitleCase,
4+
convertIntrospectedSchemaToObjects,
5+
} from './util';
6+
import type { IntrospectedSchema } from './util';
7+
8+
describe('toTitleCase', () => {
9+
it('should convert snake_case to Title Case', () => {
10+
expect(toTitleCase('first_name')).toBe('First Name');
11+
expect(toTitleCase('project_task')).toBe('Project Task');
12+
});
13+
14+
it('should capitalize single words', () => {
15+
expect(toTitleCase('name')).toBe('Name');
16+
expect(toTitleCase('status')).toBe('Status');
17+
});
18+
19+
it('should handle multiple underscores', () => {
20+
expect(toTitleCase('long_multi_word_name')).toBe('Long Multi Word Name');
21+
});
22+
23+
it('should handle empty string', () => {
24+
expect(toTitleCase('')).toBe('');
25+
});
26+
});
27+
28+
describe('convertIntrospectedSchemaToObjects', () => {
29+
const sampleSchema: IntrospectedSchema = {
30+
tables: {
31+
users: {
32+
name: 'users',
33+
columns: [
34+
{ name: 'id', type: 'integer', nullable: false, isPrimary: true },
35+
{ name: 'name', type: 'varchar', nullable: false, maxLength: 255 },
36+
{ name: 'email', type: 'varchar', nullable: false, isUnique: true, maxLength: 320 },
37+
{ name: 'bio', type: 'text', nullable: true },
38+
{ name: 'age', type: 'integer', nullable: true },
39+
{ name: 'is_active', type: 'boolean', nullable: false, defaultValue: true },
40+
{ name: 'created_at', type: 'timestamp', nullable: false },
41+
{ name: 'updated_at', type: 'timestamp', nullable: true },
42+
],
43+
foreignKeys: [],
44+
primaryKeys: ['id'],
45+
},
46+
posts: {
47+
name: 'posts',
48+
columns: [
49+
{ name: 'id', type: 'integer', nullable: false, isPrimary: true },
50+
{ name: 'title', type: 'varchar', nullable: false, maxLength: 500 },
51+
{ name: 'body', type: 'text', nullable: true },
52+
{ name: 'author_id', type: 'integer', nullable: false },
53+
{ name: 'metadata', type: 'jsonb', nullable: true },
54+
{ name: 'published_at', type: 'date', nullable: true },
55+
{ name: 'created_at', type: 'timestamp', nullable: false },
56+
{ name: 'updated_at', type: 'timestamp', nullable: true },
57+
],
58+
foreignKeys: [
59+
{
60+
columnName: 'author_id',
61+
referencedTable: 'users',
62+
referencedColumn: 'id',
63+
constraintName: 'fk_posts_author',
64+
},
65+
],
66+
primaryKeys: ['id'],
67+
},
68+
},
69+
};
70+
71+
it('should convert all tables by default', () => {
72+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
73+
expect(objects).toHaveLength(2);
74+
expect(objects.map((o) => o.name)).toEqual(['users', 'posts']);
75+
});
76+
77+
it('should skip system columns by default', () => {
78+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
79+
const users = objects.find((o) => o.name === 'users')!;
80+
const fieldNames = Object.keys(users.fields);
81+
expect(fieldNames).not.toContain('id');
82+
expect(fieldNames).not.toContain('created_at');
83+
expect(fieldNames).not.toContain('updated_at');
84+
expect(fieldNames).toContain('name');
85+
expect(fieldNames).toContain('email');
86+
});
87+
88+
it('should include system columns when skipSystemColumns=false', () => {
89+
const objects = convertIntrospectedSchemaToObjects(sampleSchema, {
90+
skipSystemColumns: false,
91+
});
92+
const users = objects.find((o) => o.name === 'users')!;
93+
const fieldNames = Object.keys(users.fields);
94+
expect(fieldNames).toContain('id');
95+
expect(fieldNames).toContain('created_at');
96+
expect(fieldNames).toContain('updated_at');
97+
});
98+
99+
it('should map varchar to text and text to textarea', () => {
100+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
101+
const users = objects.find((o) => o.name === 'users')!;
102+
expect(users.fields.name.type).toBe('text');
103+
expect(users.fields.bio.type).toBe('textarea');
104+
});
105+
106+
it('should map integer to number', () => {
107+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
108+
const users = objects.find((o) => o.name === 'users')!;
109+
expect(users.fields.age.type).toBe('number');
110+
});
111+
112+
it('should map boolean to boolean', () => {
113+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
114+
const users = objects.find((o) => o.name === 'users')!;
115+
expect(users.fields.is_active.type).toBe('boolean');
116+
expect(users.fields.is_active.defaultValue).toBe(true);
117+
});
118+
119+
it('should map jsonb to json', () => {
120+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
121+
const posts = objects.find((o) => o.name === 'posts')!;
122+
expect(posts.fields.metadata.type).toBe('json');
123+
});
124+
125+
it('should map date to date', () => {
126+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
127+
const posts = objects.find((o) => o.name === 'posts')!;
128+
expect(posts.fields.published_at.type).toBe('date');
129+
});
130+
131+
it('should map foreign keys to lookup fields', () => {
132+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
133+
const posts = objects.find((o) => o.name === 'posts')!;
134+
expect(posts.fields.author_id.type).toBe('lookup');
135+
expect(posts.fields.author_id.reference).toBe('users');
136+
});
137+
138+
it('should set unique constraint', () => {
139+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
140+
const users = objects.find((o) => o.name === 'users')!;
141+
expect(users.fields.email.unique).toBe(true);
142+
});
143+
144+
it('should set maxLength for text fields', () => {
145+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
146+
const users = objects.find((o) => o.name === 'users')!;
147+
expect(users.fields.name.maxLength).toBe(255);
148+
expect(users.fields.email.maxLength).toBe(320);
149+
});
150+
151+
it('should set required based on nullable', () => {
152+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
153+
const users = objects.find((o) => o.name === 'users')!;
154+
expect(users.fields.name.required).toBe(true);
155+
expect(users.fields.bio.required).toBe(false);
156+
});
157+
158+
it('should generate labels from table/field names', () => {
159+
const objects = convertIntrospectedSchemaToObjects(sampleSchema);
160+
const users = objects.find((o) => o.name === 'users')!;
161+
expect(users.label).toBe('Users');
162+
expect(users.fields.is_active.label).toBe('Is Active');
163+
});
164+
165+
it('should exclude tables when excludeTables is specified', () => {
166+
const objects = convertIntrospectedSchemaToObjects(sampleSchema, {
167+
excludeTables: ['posts'],
168+
});
169+
expect(objects).toHaveLength(1);
170+
expect(objects[0].name).toBe('users');
171+
});
172+
173+
it('should include only specified tables when includeTables is specified', () => {
174+
const objects = convertIntrospectedSchemaToObjects(sampleSchema, {
175+
includeTables: ['posts'],
176+
});
177+
expect(objects).toHaveLength(1);
178+
expect(objects[0].name).toBe('posts');
179+
});
180+
181+
it('should handle empty schema', () => {
182+
const objects = convertIntrospectedSchemaToObjects({ tables: {} });
183+
expect(objects).toHaveLength(0);
184+
});
185+
186+
it('should handle numeric types (float, decimal, real)', () => {
187+
const schema: IntrospectedSchema = {
188+
tables: {
189+
metrics: {
190+
name: 'metrics',
191+
columns: [
192+
{ name: 'price', type: 'decimal', nullable: false },
193+
{ name: 'weight', type: 'float', nullable: true },
194+
{ name: 'score', type: 'real', nullable: true },
195+
{ name: 'quantity', type: 'bigint', nullable: false },
196+
],
197+
foreignKeys: [],
198+
primaryKeys: [],
199+
},
200+
},
201+
};
202+
const objects = convertIntrospectedSchemaToObjects(schema);
203+
const metrics = objects[0];
204+
expect(metrics.fields.price.type).toBe('number');
205+
expect(metrics.fields.weight.type).toBe('number');
206+
expect(metrics.fields.score.type).toBe('number');
207+
expect(metrics.fields.quantity.type).toBe('number');
208+
});
209+
210+
it('should handle time type', () => {
211+
const schema: IntrospectedSchema = {
212+
tables: {
213+
schedule: {
214+
name: 'schedule',
215+
columns: [
216+
{ name: 'start_time', type: 'time', nullable: false },
217+
],
218+
foreignKeys: [],
219+
primaryKeys: [],
220+
},
221+
},
222+
};
223+
const objects = convertIntrospectedSchemaToObjects(schema);
224+
expect(objects[0].fields.start_time.type).toBe('time');
225+
});
226+
});

0 commit comments

Comments
 (0)