Skip to content

Commit a2de031

Browse files
authored
Merge pull request #70 from objectstack-ai/copilot/sync-sql-database-objects
2 parents 3fb3918 + 790038f commit a2de031

File tree

11 files changed

+849
-2
lines changed

11 files changed

+849
-2
lines changed

packages/drivers/sql/src/index.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,12 @@ export class SqlDriver implements Driver {
387387
case 'file':
388388
case 'avatar':
389389
case 'location': col = table.json(name); break;
390+
case 'lookup':
391+
col = table.string(name);
392+
if (field.reference_to) {
393+
table.foreign(name).references('id').inTable(field.reference_to);
394+
}
395+
break;
390396
case 'summary': col = table.float(name); break; // Stored calculation result
391397
case 'auto_number': col = table.string(name); break; // Generated string
392398
case 'formula': return; // Virtual field, do not create column
@@ -532,6 +538,17 @@ export class SqlDriver implements Driver {
532538
const columns = await this.introspectColumns(tableName);
533539
const foreignKeys = await this.introspectForeignKeys(tableName);
534540
const primaryKeys = await this.introspectPrimaryKeys(tableName);
541+
const uniqueConstraints = await this.introspectUniqueConstraints(tableName);
542+
543+
// Update columns with primary key and unique information
544+
for (const col of columns) {
545+
if (primaryKeys.includes(col.name)) {
546+
col.isPrimary = true;
547+
}
548+
if (uniqueConstraints.includes(col.name)) {
549+
col.isUnique = true;
550+
}
551+
}
535552

536553
tables[tableName] = {
537554
name: tableName,
@@ -730,6 +747,72 @@ export class SqlDriver implements Driver {
730747
return primaryKeys;
731748
}
732749

750+
/**
751+
* Get unique constraint information for a specific table.
752+
*/
753+
private async introspectUniqueConstraints(tableName: string): Promise<string[]> {
754+
const uniqueColumns: string[] = [];
755+
756+
try {
757+
if (this.config.client === 'pg' || this.config.client === 'postgresql') {
758+
const result = await this.knex.raw(`
759+
SELECT c.column_name
760+
FROM information_schema.table_constraints tc
761+
JOIN information_schema.constraint_column_usage AS ccu
762+
ON tc.constraint_schema = ccu.constraint_schema
763+
AND tc.constraint_name = ccu.constraint_name
764+
WHERE tc.constraint_type = 'UNIQUE'
765+
AND tc.table_name = ?
766+
`, [tableName]);
767+
768+
for (const row of result.rows) {
769+
uniqueColumns.push(row.column_name);
770+
}
771+
} else if (this.config.client === 'mysql' || this.config.client === 'mysql2') {
772+
const result = await this.knex.raw(`
773+
SELECT COLUMN_NAME
774+
FROM information_schema.TABLE_CONSTRAINTS tc
775+
JOIN information_schema.KEY_COLUMN_USAGE kcu
776+
USING (CONSTRAINT_NAME, TABLE_SCHEMA, TABLE_NAME)
777+
WHERE CONSTRAINT_TYPE = 'UNIQUE'
778+
AND TABLE_SCHEMA = DATABASE()
779+
AND TABLE_NAME = ?
780+
`, [tableName]);
781+
782+
for (const row of result[0]) {
783+
uniqueColumns.push(row.COLUMN_NAME);
784+
}
785+
} else if (this.config.client === 'sqlite3') {
786+
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
787+
788+
// Validate table exists
789+
const tablesResult = await this.knex.raw("SELECT name FROM sqlite_master WHERE type = 'table'");
790+
const tableNames = Array.isArray(tablesResult) ? tablesResult.map((row: any) => row.name) : [];
791+
792+
if (!tableNames.includes(safeTableName)) {
793+
return uniqueColumns;
794+
}
795+
796+
const indexes = await this.knex.raw(`PRAGMA index_list(${safeTableName})`);
797+
798+
for (const idx of indexes) {
799+
// Check if unique
800+
if (idx.unique === 1) {
801+
const info = await this.knex.raw(`PRAGMA index_info(${idx.name})`);
802+
// Only handle single column unique constraints for now
803+
if (info.length === 1) {
804+
uniqueColumns.push(info[0].name);
805+
}
806+
}
807+
}
808+
}
809+
} catch (error) {
810+
console.warn('Could not introspect unique constraints for a table:', error);
811+
}
812+
813+
return uniqueColumns;
814+
}
815+
733816
async disconnect() {
734817
await this.knex.destroy();
735818
}

packages/foundation/core/src/app.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,15 @@ export class ObjectQL implements IObjectQL {
197197
return objects;
198198
}
199199

200+
async close() {
201+
for (const [name, driver] of Object.entries(this.datasources)) {
202+
if (driver.disconnect) {
203+
console.log(`Closing driver '${name}'...`);
204+
await driver.disconnect();
205+
}
206+
}
207+
}
208+
200209
async init() {
201210
// 0. Init Plugins (This allows plugins to register custom loaders)
202211
for (const plugin of this.pluginsList) {

packages/foundation/types/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface IObjectQL {
1010
getConfigs(): Record<string, ObjectConfig>;
1111
datasource(name: string): Driver;
1212
init(): Promise<void>;
13+
close?(): Promise<void>;
1314
removePackage(name: string): void;
1415
metadata: MetadataRegistry;
1516

packages/tools/cli/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,79 @@ objectql migrate status --config ./config/objectql.config.ts
356356
- `-c, --config <path>` - Path to objectql.config.ts/js
357357
- `-d, --dir <path>` - Migrations directory [default: "./migrations"]
358358

359+
#### `sync`
360+
361+
Introspect an existing SQL database and generate ObjectQL `.object.yml` files from the database schema. This is useful for:
362+
- Connecting to an existing/legacy database
363+
- Reverse-engineering database schema to ObjectQL metadata
364+
- Bootstrapping a new ObjectQL project from an existing database
365+
366+
```bash
367+
# Sync all tables from the database
368+
objectql sync
369+
370+
# Sync specific tables only
371+
objectql sync --tables users posts comments
372+
373+
# Custom output directory
374+
objectql sync --output ./src/metadata/objects
375+
376+
# Overwrite existing files
377+
objectql sync --force
378+
379+
# With custom config file
380+
objectql sync --config ./config/objectql.config.ts
381+
```
382+
383+
**Options:**
384+
- `-c, --config <path>` - Path to objectql.config.ts/js
385+
- `-o, --output <path>` - Output directory for .object.yml files [default: "./src/objects"]
386+
- `-t, --tables <tables...>` - Specific tables to sync (default: all tables)
387+
- `-f, --force` - Overwrite existing .object.yml files
388+
389+
**Features:**
390+
- Automatically detects table structure (columns, data types, constraints)
391+
- Maps SQL types to appropriate ObjectQL field types
392+
- Identifies foreign keys and converts them to `lookup` relationships
393+
- Generates human-readable labels from table/column names
394+
- Preserves field constraints (required, unique, maxLength)
395+
- Skips system fields (id, created_at, updated_at) as they're automatic in ObjectQL
396+
397+
**Example:**
398+
399+
Given a database with this table structure:
400+
```sql
401+
CREATE TABLE users (
402+
id VARCHAR PRIMARY KEY,
403+
username VARCHAR UNIQUE NOT NULL,
404+
email VARCHAR NOT NULL,
405+
is_active BOOLEAN DEFAULT true,
406+
created_at TIMESTAMP,
407+
updated_at TIMESTAMP
408+
);
409+
```
410+
411+
Running `objectql sync` generates:
412+
```yaml
413+
# users.object.yml
414+
name: users
415+
label: Users
416+
fields:
417+
username:
418+
type: text
419+
label: Username
420+
required: true
421+
unique: true
422+
email:
423+
type: text
424+
label: Email
425+
required: true
426+
is_active:
427+
type: boolean
428+
label: Is Active
429+
defaultValue: true
430+
```
431+
359432
---
360433
361434
### Development Tools

packages/tools/cli/USAGE_EXAMPLES.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,153 @@ export async function up(app: ObjectQL) {
517517
}
518518
```
519519

520+
### Syncing from Existing Database
521+
522+
The `sync` command introspects an existing SQL database and generates ObjectQL `.object.yml` files.
523+
524+
#### Basic Sync
525+
526+
```bash
527+
# Sync all tables from the database
528+
objectql sync
529+
530+
# Output: src/objects/users.object.yml, src/objects/posts.object.yml, etc.
531+
```
532+
533+
#### Selective Table Sync
534+
535+
```bash
536+
# Sync only specific tables
537+
objectql sync --tables users posts comments
538+
539+
# Custom output directory
540+
objectql sync --output ./src/metadata/objects
541+
```
542+
543+
#### Overwriting Existing Files
544+
545+
```bash
546+
# Force overwrite of existing .object.yml files
547+
objectql sync --force
548+
```
549+
550+
#### Example Workflow: Connecting to Legacy Database
551+
552+
```bash
553+
# 1. Create config file pointing to existing database
554+
cat > objectql.config.ts << 'EOF'
555+
import { ObjectQL } from '@objectql/core';
556+
import { SqlDriver } from '@objectql/driver-sql';
557+
558+
const driver = new SqlDriver({
559+
client: 'postgresql',
560+
connection: {
561+
host: 'localhost',
562+
database: 'legacy_db',
563+
user: 'postgres',
564+
password: 'password'
565+
}
566+
});
567+
568+
export default new ObjectQL({
569+
datasources: { default: driver }
570+
});
571+
EOF
572+
573+
# 2. Introspect and generate object definitions
574+
objectql sync --output ./src/objects
575+
576+
# 3. Review generated files
577+
ls -la ./src/objects/
578+
579+
# Output:
580+
# users.object.yml
581+
# products.object.yml
582+
# orders.object.yml
583+
# order_items.object.yml
584+
585+
# 4. Inspect a generated file
586+
cat ./src/objects/users.object.yml
587+
```
588+
589+
**Generated Output Example:**
590+
591+
```yaml
592+
# users.object.yml
593+
name: users
594+
label: Users
595+
fields:
596+
username:
597+
type: text
598+
label: Username
599+
required: true
600+
unique: true
601+
email:
602+
type: text
603+
label: Email
604+
required: true
605+
first_name:
606+
type: text
607+
label: First Name
608+
last_name:
609+
type: text
610+
label: Last Name
611+
is_active:
612+
type: boolean
613+
label: Is Active
614+
defaultValue: true
615+
role_id:
616+
type: lookup
617+
label: Role Id
618+
reference_to: roles
619+
```
620+
621+
**Type Mapping:**
622+
623+
The sync command automatically maps SQL types to ObjectQL field types:
624+
625+
| SQL Type | ObjectQL Type |
626+
|----------|---------------|
627+
| INT, INTEGER, BIGINT, SERIAL | number |
628+
| FLOAT, DOUBLE, DECIMAL, NUMERIC | number |
629+
| BOOLEAN, BIT | boolean |
630+
| VARCHAR, CHAR | text |
631+
| TEXT, CLOB, LONGTEXT | textarea |
632+
| TIMESTAMP, DATETIME | datetime |
633+
| DATE | date |
634+
| TIME | time |
635+
| JSON, JSONB | object |
636+
| BLOB, BINARY, BYTEA | file |
637+
638+
**Foreign Key Detection:**
639+
640+
Foreign keys are automatically detected and converted to `lookup` fields:
641+
642+
```sql
643+
-- Database Schema
644+
CREATE TABLE posts (
645+
id VARCHAR PRIMARY KEY,
646+
title VARCHAR NOT NULL,
647+
author_id VARCHAR REFERENCES users(id),
648+
...
649+
);
650+
```
651+
652+
```yaml
653+
# Generated posts.object.yml
654+
name: posts
655+
label: Posts
656+
fields:
657+
title:
658+
type: text
659+
label: Title
660+
required: true
661+
author_id:
662+
type: lookup
663+
label: Author Id
664+
reference_to: users
665+
```
666+
520667
---
521668

522669
## Development Tools

0 commit comments

Comments
 (0)