@objectstack/objectql 4.0.4 → 4.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/src/util.test.ts DELETED
@@ -1,226 +0,0 @@
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
- });
package/src/util.ts DELETED
@@ -1,219 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { ServiceObject } from '@objectstack/spec/data';
4
-
5
- // ── Introspection Types ──────────────────────────────────────────────────────
6
-
7
- /**
8
- * Column metadata from database introspection.
9
- */
10
- export interface IntrospectedColumn {
11
- /** Column name */
12
- name: string;
13
- /** Native database type (e.g., 'varchar', 'integer', 'timestamp') */
14
- type: string;
15
- /** Whether the column is nullable */
16
- nullable: boolean;
17
- /** Default value if any */
18
- defaultValue?: unknown;
19
- /** Whether this is a primary key */
20
- isPrimary?: boolean;
21
- /** Whether this column has a unique constraint */
22
- isUnique?: boolean;
23
- /** Maximum length for string types */
24
- maxLength?: number;
25
- }
26
-
27
- /**
28
- * Foreign key relationship metadata.
29
- */
30
- export interface IntrospectedForeignKey {
31
- /** Column name in the source table */
32
- columnName: string;
33
- /** Referenced table name */
34
- referencedTable: string;
35
- /** Referenced column name */
36
- referencedColumn: string;
37
- /** Constraint name */
38
- constraintName?: string;
39
- }
40
-
41
- /**
42
- * Table metadata from database introspection.
43
- */
44
- export interface IntrospectedTable {
45
- /** Table name */
46
- name: string;
47
- /** List of columns */
48
- columns: IntrospectedColumn[];
49
- /** List of foreign key relationships */
50
- foreignKeys: IntrospectedForeignKey[];
51
- /** Primary key columns */
52
- primaryKeys: string[];
53
- }
54
-
55
- /**
56
- * Complete database schema introspection result.
57
- */
58
- export interface IntrospectedSchema {
59
- /** Map of table name to table metadata */
60
- tables: Record<string, IntrospectedTable>;
61
- }
62
-
63
- // ── Utility Functions ────────────────────────────────────────────────────────
64
-
65
- /**
66
- * Convert a snake_case or plain string to Title Case.
67
- *
68
- * @example
69
- * toTitleCase('first_name') // => 'First Name'
70
- * toTitleCase('project_task') // => 'Project Task'
71
- */
72
- export function toTitleCase(str: string): string {
73
- return str
74
- .replace(/_/g, ' ')
75
- .replace(/\b\w/g, (char) => char.toUpperCase());
76
- }
77
-
78
- /**
79
- * Map a native database column type to an ObjectStack FieldType.
80
- */
81
- function mapDatabaseTypeToFieldType(
82
- dbType: string
83
- ): 'text' | 'textarea' | 'number' | 'boolean' | 'datetime' | 'date' | 'time' | 'json' {
84
- const type = dbType.toLowerCase();
85
-
86
- // Text types
87
- if (type.includes('char') || type.includes('varchar') || type.includes('text')) {
88
- if (type.includes('text')) return 'textarea';
89
- return 'text';
90
- }
91
-
92
- // Numeric types
93
- if (
94
- type.includes('int') || type === 'integer' || type === 'bigint' || type === 'smallint'
95
- ) {
96
- return 'number';
97
- }
98
- if (
99
- type.includes('float') || type.includes('double') || type.includes('decimal') ||
100
- type.includes('numeric') || type === 'real'
101
- ) {
102
- return 'number';
103
- }
104
-
105
- // Boolean
106
- if (type.includes('bool')) {
107
- return 'boolean';
108
- }
109
-
110
- // Date / Time types
111
- if (type.includes('timestamp') || type === 'datetime') {
112
- return 'datetime';
113
- }
114
- if (type === 'date') {
115
- return 'date';
116
- }
117
- if (type === 'time') {
118
- return 'time';
119
- }
120
-
121
- // JSON types
122
- if (type === 'json' || type === 'jsonb') {
123
- return 'json';
124
- }
125
-
126
- // Default to text
127
- return 'text';
128
- }
129
-
130
- /**
131
- * Convert an introspected database schema to ObjectStack object definitions.
132
- *
133
- * This allows using existing database tables without manually defining metadata.
134
- *
135
- * @param introspectedSchema - The schema returned from driver.introspectSchema()
136
- * @param options - Optional filtering / conversion settings
137
- * @returns Array of ServiceObject definitions that can be registered with ObjectQL
138
- *
139
- * @example
140
- * ```typescript
141
- * const schema = await driver.introspectSchema();
142
- * const objects = convertIntrospectedSchemaToObjects(schema);
143
- * for (const obj of objects) {
144
- * engine.registerObject(obj);
145
- * }
146
- * ```
147
- */
148
- export function convertIntrospectedSchemaToObjects(
149
- introspectedSchema: IntrospectedSchema,
150
- options?: {
151
- /** Tables to exclude from conversion */
152
- excludeTables?: string[];
153
- /** Tables to include (if specified, only these will be converted) */
154
- includeTables?: string[];
155
- /** Whether to skip system columns like id, created_at, updated_at (default: true) */
156
- skipSystemColumns?: boolean;
157
- }
158
- ): ServiceObject[] {
159
- const objects: ServiceObject[] = [];
160
- const excludeTables = options?.excludeTables || [];
161
- const includeTables = options?.includeTables;
162
- const skipSystemColumns = options?.skipSystemColumns !== false;
163
-
164
- for (const [tableName, table] of Object.entries(introspectedSchema.tables)) {
165
- if (excludeTables.includes(tableName)) continue;
166
- if (includeTables && !includeTables.includes(tableName)) continue;
167
-
168
- const fields: Record<string, any> = {};
169
-
170
- for (const column of table.columns) {
171
- // Skip system columns if requested
172
- if (skipSystemColumns && ['id', 'created_at', 'updated_at'].includes(column.name)) {
173
- continue;
174
- }
175
-
176
- // Check for foreign key → lookup field
177
- const foreignKey = table.foreignKeys.find((fk) => fk.columnName === column.name);
178
-
179
- if (foreignKey) {
180
- fields[column.name] = {
181
- name: column.name,
182
- type: 'lookup' as const,
183
- reference: foreignKey.referencedTable,
184
- label: toTitleCase(column.name),
185
- required: !column.nullable,
186
- };
187
- } else {
188
- const fieldType = mapDatabaseTypeToFieldType(column.type);
189
-
190
- const field: Record<string, any> = {
191
- name: column.name,
192
- type: fieldType,
193
- label: toTitleCase(column.name),
194
- required: !column.nullable,
195
- };
196
-
197
- if (column.isUnique) {
198
- field.unique = true;
199
- }
200
- if (column.maxLength && (fieldType === 'text' || fieldType === 'textarea')) {
201
- field.maxLength = column.maxLength;
202
- }
203
- if (column.defaultValue != null) {
204
- field.defaultValue = column.defaultValue;
205
- }
206
-
207
- fields[column.name] = field;
208
- }
209
- }
210
-
211
- objects.push({
212
- name: tableName,
213
- label: toTitleCase(tableName),
214
- fields,
215
- } as ServiceObject);
216
- }
217
-
218
- return objects;
219
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*"],
4
- "exclude": ["node_modules", "dist", "**/*.test.ts"],
5
- "compilerOptions": {
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "types": ["node"]
9
- }
10
- }