@xacos/orm 1.0.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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/XModel.d.ts +42 -0
- package/dist/XModel.d.ts.map +1 -0
- package/dist/XModel.js +240 -0
- package/dist/XModel.js.map +1 -0
- package/dist/XModel.test.d.ts +2 -0
- package/dist/XModel.test.d.ts.map +1 -0
- package/dist/XModel.test.js +119 -0
- package/dist/XModel.test.js.map +1 -0
- package/dist/XMongoModel.d.ts +25 -0
- package/dist/XMongoModel.d.ts.map +1 -0
- package/dist/XMongoModel.js +86 -0
- package/dist/XMongoModel.js.map +1 -0
- package/dist/XMongoModel.test.d.ts +2 -0
- package/dist/XMongoModel.test.d.ts.map +1 -0
- package/dist/XMongoModel.test.js +14 -0
- package/dist/XMongoModel.test.js.map +1 -0
- package/dist/__tests__/sqlite.integration.test.d.ts +2 -0
- package/dist/__tests__/sqlite.integration.test.d.ts.map +1 -0
- package/dist/__tests__/sqlite.integration.test.js +106 -0
- package/dist/__tests__/sqlite.integration.test.js.map +1 -0
- package/dist/connection/db.d.ts +25 -0
- package/dist/connection/db.d.ts.map +1 -0
- package/dist/connection/db.js +100 -0
- package/dist/connection/db.js.map +1 -0
- package/dist/connection/drivers.d.ts +3 -0
- package/dist/connection/drivers.d.ts.map +1 -0
- package/dist/connection/drivers.js +58 -0
- package/dist/connection/drivers.js.map +1 -0
- package/dist/connection/mongoUri.d.ts +6 -0
- package/dist/connection/mongoUri.d.ts.map +1 -0
- package/dist/connection/mongoUri.js +22 -0
- package/dist/connection/mongoUri.js.map +1 -0
- package/dist/decorators.d.ts +47 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +149 -0
- package/dist/decorators.js.map +1 -0
- package/dist/factories/Factory.d.ts +34 -0
- package/dist/factories/Factory.d.ts.map +1 -0
- package/dist/factories/Factory.js +48 -0
- package/dist/factories/Factory.js.map +1 -0
- package/dist/factories/Factory.test.d.ts +2 -0
- package/dist/factories/Factory.test.d.ts.map +1 -0
- package/dist/factories/Factory.test.js +16 -0
- package/dist/factories/Factory.test.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/Migration.d.ts +6 -0
- package/dist/migrations/Migration.d.ts.map +1 -0
- package/dist/migrations/Migration.js +3 -0
- package/dist/migrations/Migration.js.map +1 -0
- package/dist/migrations/MigrationRunner.d.ts +14 -0
- package/dist/migrations/MigrationRunner.d.ts.map +1 -0
- package/dist/migrations/MigrationRunner.js +142 -0
- package/dist/migrations/MigrationRunner.js.map +1 -0
- package/dist/migrations/MigrationRunner.test.d.ts +2 -0
- package/dist/migrations/MigrationRunner.test.d.ts.map +1 -0
- package/dist/migrations/MigrationRunner.test.js +26 -0
- package/dist/migrations/MigrationRunner.test.js.map +1 -0
- package/dist/migrations/Schema.d.ts +7 -0
- package/dist/migrations/Schema.d.ts.map +1 -0
- package/dist/migrations/Schema.js +17 -0
- package/dist/migrations/Schema.js.map +1 -0
- package/dist/migrations/columnHelpers.d.ts +9 -0
- package/dist/migrations/columnHelpers.d.ts.map +1 -0
- package/dist/migrations/columnHelpers.js +15 -0
- package/dist/migrations/columnHelpers.js.map +1 -0
- package/dist/seeders/Seeder.d.ts +8 -0
- package/dist/seeders/Seeder.d.ts.map +1 -0
- package/dist/seeders/Seeder.js +3 -0
- package/dist/seeders/Seeder.js.map +1 -0
- package/dist/seeders/SeederRunner.d.ts +20 -0
- package/dist/seeders/SeederRunner.d.ts.map +1 -0
- package/dist/seeders/SeederRunner.js +68 -0
- package/dist/seeders/SeederRunner.js.map +1 -0
- package/dist/seeders/SeederRunner.test.d.ts +2 -0
- package/dist/seeders/SeederRunner.test.d.ts.map +1 -0
- package/dist/seeders/SeederRunner.test.js +44 -0
- package/dist/seeders/SeederRunner.test.js.map +1 -0
- package/dist/utils/paginate.d.ts +14 -0
- package/dist/utils/paginate.d.ts.map +1 -0
- package/dist/utils/paginate.js +28 -0
- package/dist/utils/paginate.js.map +1 -0
- package/dist/utils/paginate.test.d.ts +2 -0
- package/dist/utils/paginate.test.d.ts.map +1 -0
- package/dist/utils/paginate.test.js +23 -0
- package/dist/utils/paginate.test.js.map +1 -0
- package/package.json +75 -0
- package/src/XModel.test.ts +147 -0
- package/src/XModel.ts +301 -0
- package/src/XMongoModel.test.ts +16 -0
- package/src/XMongoModel.ts +119 -0
- package/src/__tests__/sqlite.integration.test.ts +116 -0
- package/src/connection/db.ts +127 -0
- package/src/connection/drivers.ts +65 -0
- package/src/connection/mongoUri.ts +25 -0
- package/src/decorators.ts +200 -0
- package/src/factories/Factory.test.ts +18 -0
- package/src/factories/Factory.ts +61 -0
- package/src/index.ts +18 -0
- package/src/migrations/Migration.ts +8 -0
- package/src/migrations/MigrationRunner.test.ts +33 -0
- package/src/migrations/MigrationRunner.ts +171 -0
- package/src/migrations/Schema.ts +20 -0
- package/src/migrations/columnHelpers.ts +28 -0
- package/src/seeders/Seeder.ts +8 -0
- package/src/seeders/SeederRunner.test.ts +62 -0
- package/src/seeders/SeederRunner.ts +76 -0
- package/src/types/bun-test.d.ts +8 -0
- package/src/utils/paginate.test.ts +24 -0
- package/src/utils/paginate.ts +37 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import mongoose, {
|
|
2
|
+
type FilterQuery,
|
|
3
|
+
type Model,
|
|
4
|
+
type Schema,
|
|
5
|
+
type SortOrder,
|
|
6
|
+
type UpdateQuery,
|
|
7
|
+
Types,
|
|
8
|
+
} from "mongoose";
|
|
9
|
+
import type { PaginatedResult } from "@xacos/shared";
|
|
10
|
+
|
|
11
|
+
type MongoDoc = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mongoose-backed persistence. With MongoDB you define a {@link Schema} on the subclass
|
|
15
|
+
* and use this API directly — **no Knex migrations** (`MigrationRunner` is SQL-only).
|
|
16
|
+
*/
|
|
17
|
+
export abstract class XMongoModel {
|
|
18
|
+
/** Registered name for `mongoose.model()` (must be unique per connection). */
|
|
19
|
+
static modelName = "";
|
|
20
|
+
/** Optional Mongo collection name (passed to the schema via `collection` option). */
|
|
21
|
+
static collection = "";
|
|
22
|
+
|
|
23
|
+
static getSchema(): Schema {
|
|
24
|
+
throw new Error("[XAOCS ORM] Override getSchema() and return a Mongoose Schema for this model.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static get model(): Model<MongoDoc & mongoose.Document> {
|
|
28
|
+
const ctor = this as typeof XMongoModel;
|
|
29
|
+
if (!ctor.modelName) {
|
|
30
|
+
throw new Error("[XAOCS ORM] static modelName is required on XMongoModel subclasses.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const existing = mongoose.models[ctor.modelName] as Model<MongoDoc & mongoose.Document> | undefined;
|
|
34
|
+
if (existing) {
|
|
35
|
+
return existing;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const schema = ctor.getSchema();
|
|
39
|
+
if (ctor.collection) {
|
|
40
|
+
schema.set("collection", ctor.collection);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return mongoose.model<MongoDoc & mongoose.Document>(ctor.modelName, schema);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async findById(id: string | Types.ObjectId): Promise<MongoDoc | null> {
|
|
47
|
+
const doc = await this.model.findById(id).lean<MongoDoc>().exec();
|
|
48
|
+
return doc ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static async findByIdOrFail(id: string | Types.ObjectId): Promise<MongoDoc> {
|
|
52
|
+
const row = await this.findById(id);
|
|
53
|
+
if (!row) {
|
|
54
|
+
const err = new Error("Not found");
|
|
55
|
+
(err as Error & { statusCode: number }).statusCode = 404;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static async findOne(filter: FilterQuery<MongoDoc>): Promise<MongoDoc | null> {
|
|
62
|
+
const doc = await this.model.findOne(filter).lean<MongoDoc>().exec();
|
|
63
|
+
return doc ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async find(filter: FilterQuery<MongoDoc> = {}): Promise<MongoDoc[]> {
|
|
67
|
+
return (await this.model.find(filter).lean<MongoDoc[]>().exec()) as MongoDoc[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static async create(data: MongoDoc): Promise<MongoDoc> {
|
|
71
|
+
const created = await this.model.create(data);
|
|
72
|
+
const plain = created.toObject() as MongoDoc;
|
|
73
|
+
const id = plain["_id"];
|
|
74
|
+
if (id !== undefined) {
|
|
75
|
+
plain["id"] = String(id);
|
|
76
|
+
}
|
|
77
|
+
return plain;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static async updateById(
|
|
81
|
+
id: string | Types.ObjectId,
|
|
82
|
+
update: UpdateQuery<MongoDoc>,
|
|
83
|
+
): Promise<MongoDoc | null> {
|
|
84
|
+
const doc = await this.model
|
|
85
|
+
.findByIdAndUpdate(id, update, { new: true, runValidators: true })
|
|
86
|
+
.lean<MongoDoc>()
|
|
87
|
+
.exec();
|
|
88
|
+
return doc ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static async deleteById(id: string | Types.ObjectId): Promise<boolean> {
|
|
92
|
+
const res = await this.model.findByIdAndDelete(id).exec();
|
|
93
|
+
return res !== null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static async paginate(
|
|
97
|
+
filter: FilterQuery<MongoDoc> = {},
|
|
98
|
+
page = 1,
|
|
99
|
+
perPage = 15,
|
|
100
|
+
sort: Record<string, SortOrder> = { _id: -1 },
|
|
101
|
+
): Promise<PaginatedResult<MongoDoc>> {
|
|
102
|
+
const skip = (page - 1) * perPage;
|
|
103
|
+
const [total, data] = await Promise.all([
|
|
104
|
+
this.model.countDocuments(filter).exec(),
|
|
105
|
+
this.model.find(filter).sort(sort).skip(skip).limit(perPage).lean<MongoDoc[]>().exec(),
|
|
106
|
+
]);
|
|
107
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
108
|
+
return {
|
|
109
|
+
data: data as MongoDoc[],
|
|
110
|
+
total,
|
|
111
|
+
page,
|
|
112
|
+
perPage,
|
|
113
|
+
lastPage,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default XMongoModel;
|
|
119
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
2
|
+
import { initDb, getKnex, closeDb } from '../connection/db';
|
|
3
|
+
import { MigrationRunner } from '../migrations/MigrationRunner';
|
|
4
|
+
import { XModel } from '../XModel';
|
|
5
|
+
import { Column } from '../decorators';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { unlinkSync, existsSync } from 'fs';
|
|
8
|
+
|
|
9
|
+
const DB_PATH = './tmp-test.sqlite';
|
|
10
|
+
|
|
11
|
+
process.env['DB_DRIVER'] = 'sqlite';
|
|
12
|
+
process.env['DB_PATH'] = DB_PATH;
|
|
13
|
+
|
|
14
|
+
class TestUser extends XModel {
|
|
15
|
+
static table = 'test_users';
|
|
16
|
+
|
|
17
|
+
@Column()
|
|
18
|
+
declare id: number;
|
|
19
|
+
|
|
20
|
+
@Column()
|
|
21
|
+
declare name: string;
|
|
22
|
+
|
|
23
|
+
@Column()
|
|
24
|
+
declare email: string;
|
|
25
|
+
|
|
26
|
+
@Column()
|
|
27
|
+
declare created_at: Date;
|
|
28
|
+
|
|
29
|
+
@Column()
|
|
30
|
+
declare updated_at: Date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
initDb();
|
|
35
|
+
const knex = getKnex();
|
|
36
|
+
|
|
37
|
+
// Create the test table directly
|
|
38
|
+
await knex.schema.createTableIfNotExists('test_users', t => {
|
|
39
|
+
t.increments('id');
|
|
40
|
+
t.string('name').notNullable();
|
|
41
|
+
t.string('email').notNullable().unique();
|
|
42
|
+
t.timestamps(true, true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await closeDb();
|
|
48
|
+
if (existsSync(DB_PATH)) unlinkSync(DB_PATH);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
await getKnex()('test_users').delete();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('SQLite Integration — XModel', () => {
|
|
56
|
+
it('creates a record', async () => {
|
|
57
|
+
const user = await TestUser.create({ name: 'Alice', email: 'alice@test.com' } as any);
|
|
58
|
+
expect((user as any).id).toBeGreaterThan(0);
|
|
59
|
+
expect((user as any).name).toBe('Alice');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('finds a record by id', async () => {
|
|
63
|
+
const created = await TestUser.create({ name: 'Bob', email: 'bob@test.com' } as any);
|
|
64
|
+
const found = await TestUser.find((created as any).id) as any;
|
|
65
|
+
expect(found).not.toBeNull();
|
|
66
|
+
expect(found.name).toBe('Bob');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null for missing id', async () => {
|
|
70
|
+
const found = await TestUser.find(9999);
|
|
71
|
+
expect(found).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('findOrFail throws for missing id', async () => {
|
|
75
|
+
await expect(TestUser.findOrFail(9999)).rejects.toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('queries with where()', async () => {
|
|
79
|
+
await TestUser.create({ name: 'Carol', email: 'carol@test.com' } as any);
|
|
80
|
+
await TestUser.create({ name: 'Dave', email: 'dave@test.com' } as any);
|
|
81
|
+
const results = await TestUser.where('name', 'Carol').select() as any[];
|
|
82
|
+
expect(results).toHaveLength(1);
|
|
83
|
+
expect(results[0].name).toBe('Carol');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns all records', async () => {
|
|
87
|
+
await TestUser.create({ name: 'Eve', email: 'eve@test.com' } as any);
|
|
88
|
+
await TestUser.create({ name: 'Frank', email: 'frank@test.com' } as any);
|
|
89
|
+
const all = await TestUser.all() as any[];
|
|
90
|
+
expect(all.length).toBeGreaterThanOrEqual(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('updates a record', async () => {
|
|
94
|
+
const user = await TestUser.create({ name: 'Grace', email: 'grace@test.com' } as any) as any;
|
|
95
|
+
await TestUser.update(user.id, { name: 'Grace Updated' });
|
|
96
|
+
const updated = await TestUser.find(user.id) as any;
|
|
97
|
+
expect(updated.name).toBe('Grace Updated');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('deletes a record', async () => {
|
|
101
|
+
const user = await TestUser.create({ name: 'Heidi', email: 'heidi@test.com' } as any) as any;
|
|
102
|
+
await TestUser.destroy(user.id);
|
|
103
|
+
const found = await TestUser.find(user.id);
|
|
104
|
+
expect(found).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('paginates results', async () => {
|
|
108
|
+
for (let i = 0; i < 20; i++) {
|
|
109
|
+
await TestUser.create({ name: `User${i}`, email: `user${i}@test.com` } as any);
|
|
110
|
+
}
|
|
111
|
+
const page = await TestUser.paginate(1, 5) as any;
|
|
112
|
+
expect(page.data).toHaveLength(5);
|
|
113
|
+
expect(page.total).toBe(20);
|
|
114
|
+
expect(page.lastPage).toBe(4);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import knex, { type Knex } from "knex";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import { buildKnexConfig } from "./drivers";
|
|
4
|
+
import { buildMongoUri } from "./mongoUri";
|
|
5
|
+
|
|
6
|
+
let knexInstance: Knex | null = null;
|
|
7
|
+
|
|
8
|
+
export type InitDbOptions =
|
|
9
|
+
| { readonly __memorySqlite: true }
|
|
10
|
+
| Knex.Config;
|
|
11
|
+
|
|
12
|
+
export function isKnexInitialized(): boolean {
|
|
13
|
+
return knexInstance !== null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isMongooseConnected(): boolean {
|
|
17
|
+
return mongoose.connection.readyState === 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function initDb(config?: InitDbOptions): Knex {
|
|
21
|
+
if (mongoose.connection.readyState === 1) {
|
|
22
|
+
throw new Error("[XAOCS ORM] Cannot initialize Knex while Mongoose is connected. Call closeDb() first.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const driver = process.env["DB_DRIVER"] ?? "sqlite";
|
|
26
|
+
if (!config && driver === "mongodb") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"[XAOCS ORM] DB_DRIVER is mongodb: use await initMongo() for Mongoose. Knex SQL migrations are not used with MongoDB.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (knexInstance) {
|
|
33
|
+
return knexInstance;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let knexConfig: Knex.Config;
|
|
37
|
+
if (config && typeof config === "object" && "__memorySqlite" in config && config.__memorySqlite) {
|
|
38
|
+
knexConfig = {
|
|
39
|
+
client: "sqlite3",
|
|
40
|
+
connection: {
|
|
41
|
+
filename: ":memory:",
|
|
42
|
+
},
|
|
43
|
+
useNullAsDefault: true,
|
|
44
|
+
pool: {
|
|
45
|
+
min: 1,
|
|
46
|
+
max: 1,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
} else if (config && typeof config === "object" && "client" in config) {
|
|
50
|
+
knexConfig = config;
|
|
51
|
+
} else {
|
|
52
|
+
knexConfig = buildKnexConfig();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
knexInstance = knex(knexConfig);
|
|
56
|
+
return knexInstance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getKnex(): Knex {
|
|
60
|
+
if (knexInstance) {
|
|
61
|
+
return knexInstance;
|
|
62
|
+
}
|
|
63
|
+
if (mongoose.connection.readyState === 1) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"[XAOCS ORM] Knex is not initialized; Mongoose is active. SQL helpers and MigrationRunner require Knex — with MongoDB define schemas on XMongoModel subclasses instead of migrations.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw new Error("[XAOCS ORM] DB not initialized. Call initDb() or await initMongo() first.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type InitMongoOptions = Readonly<{
|
|
72
|
+
uri?: string;
|
|
73
|
+
/** Passed to `mongoose.connect` (pool size, timeouts, TLS, etc.). */
|
|
74
|
+
connectOptions?: mongoose.ConnectOptions;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Connect Mongoose (MongoDB). Does not use Knex; do not run SQL migrations — define schemas on {@link XMongoModel} instead.
|
|
79
|
+
*/
|
|
80
|
+
export async function initMongo(options?: InitMongoOptions): Promise<typeof mongoose> {
|
|
81
|
+
if (knexInstance) {
|
|
82
|
+
throw new Error("[XAOCS ORM] Cannot connect Mongoose while Knex is active. Call closeDb() first.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (mongoose.connection.readyState === 1) {
|
|
86
|
+
return mongoose;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const uri = options?.uri ?? buildMongoUri();
|
|
90
|
+
const defaults: mongoose.ConnectOptions = {
|
|
91
|
+
serverSelectionTimeoutMS: 10_000,
|
|
92
|
+
maxPoolSize: 10,
|
|
93
|
+
};
|
|
94
|
+
await mongoose.connect(uri, { ...defaults, ...options?.connectOptions });
|
|
95
|
+
return mongoose;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Auto-select Knex vs Mongoose from `DB_DRIVER` (mongodb → Mongoose, otherwise → Knex).
|
|
100
|
+
*/
|
|
101
|
+
export async function initOrmFromEnv(): Promise<"knex" | "mongoose"> {
|
|
102
|
+
const driver = process.env["DB_DRIVER"] ?? "sqlite";
|
|
103
|
+
if (driver === "mongodb") {
|
|
104
|
+
await initMongo();
|
|
105
|
+
return "mongoose";
|
|
106
|
+
}
|
|
107
|
+
initDb();
|
|
108
|
+
return "knex";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getMongoose(): typeof mongoose {
|
|
112
|
+
if (mongoose.connection.readyState !== 1) {
|
|
113
|
+
throw new Error("[XAOCS ORM] Mongoose is not connected. Call await initMongo() first.");
|
|
114
|
+
}
|
|
115
|
+
return mongoose;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function closeDb(): Promise<void> {
|
|
119
|
+
if (knexInstance) {
|
|
120
|
+
await knexInstance.destroy();
|
|
121
|
+
knexInstance = null;
|
|
122
|
+
}
|
|
123
|
+
if (mongoose.connection.readyState !== 0) {
|
|
124
|
+
await mongoose.disconnect();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
function mysqlConnection(): Knex.MySql2ConnectionConfig {
|
|
4
|
+
const database = process.env["DB_DATABASE"];
|
|
5
|
+
const user = process.env["DB_USERNAME"];
|
|
6
|
+
const password = process.env["DB_PASSWORD"];
|
|
7
|
+
if (!database || !user) {
|
|
8
|
+
throw new Error("[XAOCS ORM] DB_DATABASE and DB_USERNAME are required for mysql driver");
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
host: process.env["DB_HOST"] ?? "localhost",
|
|
12
|
+
port: Number(process.env["DB_PORT"] ?? 3306),
|
|
13
|
+
database,
|
|
14
|
+
user,
|
|
15
|
+
password: password ?? "",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function postgresConnection(): Knex.PgConnectionConfig {
|
|
20
|
+
const database = process.env["DB_DATABASE"];
|
|
21
|
+
const user = process.env["DB_USERNAME"];
|
|
22
|
+
const password = process.env["DB_PASSWORD"];
|
|
23
|
+
if (!database || !user) {
|
|
24
|
+
throw new Error("[XAOCS ORM] DB_DATABASE and DB_USERNAME are required for postgres driver");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
host: process.env["DB_HOST"] ?? "localhost",
|
|
28
|
+
port: Number(process.env["DB_PORT"] ?? 5432),
|
|
29
|
+
database,
|
|
30
|
+
user,
|
|
31
|
+
password: password ?? "",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildKnexConfig(): Knex.Config {
|
|
36
|
+
const driver = process.env["DB_DRIVER"] ?? "sqlite";
|
|
37
|
+
|
|
38
|
+
switch (driver) {
|
|
39
|
+
case "mysql":
|
|
40
|
+
return {
|
|
41
|
+
client: "mysql2",
|
|
42
|
+
connection: mysqlConnection(),
|
|
43
|
+
};
|
|
44
|
+
case "postgres":
|
|
45
|
+
return {
|
|
46
|
+
client: "pg",
|
|
47
|
+
connection: postgresConnection(),
|
|
48
|
+
};
|
|
49
|
+
case "sqlite":
|
|
50
|
+
return {
|
|
51
|
+
client: "sqlite3",
|
|
52
|
+
connection: {
|
|
53
|
+
filename: process.env["DB_PATH"] ?? "./database/db.sqlite",
|
|
54
|
+
},
|
|
55
|
+
useNullAsDefault: true,
|
|
56
|
+
};
|
|
57
|
+
case "mongodb":
|
|
58
|
+
throw new Error(
|
|
59
|
+
"[XAOCS ORM] MongoDB does not use Knex. Set DB_DRIVER=mongodb and call await initMongo() (or initOrmFromEnv()). Define Mongoose schemas on XMongoModel subclasses — SQL migrations do not apply.",
|
|
60
|
+
);
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`[XAOCS ORM] Unsupported driver: ${driver}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a Mongo connection URI from env.
|
|
3
|
+
* Prefer `MONGODB_URI` or `DATABASE_URL` when set.
|
|
4
|
+
*/
|
|
5
|
+
export function buildMongoUri(): string {
|
|
6
|
+
const direct = process.env["MONGODB_URI"] ?? process.env["DATABASE_URL"];
|
|
7
|
+
if (direct) {
|
|
8
|
+
return direct;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const user = process.env["DB_USERNAME"] ?? "";
|
|
12
|
+
const password = process.env["DB_PASSWORD"] ?? "";
|
|
13
|
+
const host = process.env["DB_HOST"] ?? "localhost";
|
|
14
|
+
const port = process.env["DB_PORT"] ?? "27017";
|
|
15
|
+
const database = process.env["DB_DATABASE"] ?? "xacos";
|
|
16
|
+
|
|
17
|
+
if (user) {
|
|
18
|
+
const u = encodeURIComponent(user);
|
|
19
|
+
const p = encodeURIComponent(password);
|
|
20
|
+
return `mongodb://${u}:${p}@${host}:${port}/${database}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `mongodb://${host}:${port}/${database}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
export type ModelConstructor = { new (...args: never[]): unknown; readonly table: string };
|
|
2
|
+
|
|
3
|
+
export type RelationMeta =
|
|
4
|
+
| Readonly<{
|
|
5
|
+
kind: "hasMany";
|
|
6
|
+
related: () => ModelConstructor;
|
|
7
|
+
foreignKey: string;
|
|
8
|
+
propertyKey: string;
|
|
9
|
+
}>
|
|
10
|
+
| Readonly<{
|
|
11
|
+
kind: "belongsTo";
|
|
12
|
+
related: () => ModelConstructor;
|
|
13
|
+
foreignKey: string;
|
|
14
|
+
propertyKey: string;
|
|
15
|
+
}>
|
|
16
|
+
| Readonly<{
|
|
17
|
+
kind: "hasOne";
|
|
18
|
+
related: () => ModelConstructor;
|
|
19
|
+
foreignKey: string;
|
|
20
|
+
propertyKey: string;
|
|
21
|
+
}>
|
|
22
|
+
| Readonly<{
|
|
23
|
+
kind: "belongsToMany";
|
|
24
|
+
related: () => ModelConstructor;
|
|
25
|
+
pivotTable: string;
|
|
26
|
+
propertyKey: string;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
export type ColumnOptions = Readonly<{
|
|
30
|
+
hidden?: boolean;
|
|
31
|
+
default?: unknown;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
type WithRelations = { __xacosRelations?: RelationMeta[] };
|
|
35
|
+
type WithColumns = { __xacosColumns?: Map<string, ColumnOptions> };
|
|
36
|
+
|
|
37
|
+
type StandardDecoratorContext = {
|
|
38
|
+
kind: string;
|
|
39
|
+
name: string | symbol;
|
|
40
|
+
addInitializer?: (initializer: () => void) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function constructorFromTarget(target: object | undefined): object {
|
|
44
|
+
if (!target || typeof target !== "object") {
|
|
45
|
+
throw new Error("[XAOCS ORM] Decorators must be applied to class members with a valid prototype target");
|
|
46
|
+
}
|
|
47
|
+
const ctor = (target as { constructor?: object }).constructor;
|
|
48
|
+
if (!ctor) {
|
|
49
|
+
throw new Error("[XAOCS ORM] Decorators require a constructor on the decorated target");
|
|
50
|
+
}
|
|
51
|
+
return ctor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isStandardDecoratorContext(value: unknown): value is StandardDecoratorContext {
|
|
55
|
+
return (
|
|
56
|
+
typeof value === "object" &&
|
|
57
|
+
value !== null &&
|
|
58
|
+
"kind" in value &&
|
|
59
|
+
"name" in value
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pushRelation(constructor: object, meta: RelationMeta): void {
|
|
64
|
+
const ctor = constructor as WithRelations;
|
|
65
|
+
if (!ctor.__xacosRelations) ctor.__xacosRelations = [];
|
|
66
|
+
ctor.__xacosRelations.push(meta);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Register relation metadata when runtime decorators receive no prototype (e.g. some Bun/TS field transforms). */
|
|
70
|
+
export function registerRelation(constructor: object, meta: RelationMeta): void {
|
|
71
|
+
pushRelation(constructor, meta);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Register column metadata without a decorator. */
|
|
75
|
+
export function registerColumnMeta(constructor: object, propertyKey: string, options: ColumnOptions): void {
|
|
76
|
+
ensureColumnMap(constructor).set(propertyKey, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureColumnMap(constructor: object): Map<string, ColumnOptions> {
|
|
80
|
+
const ctor = constructor as WithColumns;
|
|
81
|
+
if (!ctor.__xacosColumns) ctor.__xacosColumns = new Map();
|
|
82
|
+
return ctor.__xacosColumns;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function HasMany(related: () => ModelConstructor, foreignKey: string) {
|
|
86
|
+
return (targetOrInitialValue: object | undefined, propertyKeyOrContext: string | symbol | StandardDecoratorContext): void => {
|
|
87
|
+
if (isStandardDecoratorContext(propertyKeyOrContext)) {
|
|
88
|
+
const context = propertyKeyOrContext;
|
|
89
|
+
if (context.kind !== "field" && context.kind !== "accessor") return;
|
|
90
|
+
const key = String(context.name);
|
|
91
|
+
context.addInitializer?.(function (this: object) {
|
|
92
|
+
const ctor = constructorFromTarget(this);
|
|
93
|
+
pushRelation(ctor, { kind: "hasMany", related, foreignKey, propertyKey: key });
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const key = String(propertyKeyOrContext);
|
|
98
|
+
const ctor = constructorFromTarget(targetOrInitialValue);
|
|
99
|
+
pushRelation(ctor, { kind: "hasMany", related, foreignKey, propertyKey: key });
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function BelongsTo(related: () => ModelConstructor, foreignKey: string) {
|
|
104
|
+
return (targetOrInitialValue: object | undefined, propertyKeyOrContext: string | symbol | StandardDecoratorContext): void => {
|
|
105
|
+
if (isStandardDecoratorContext(propertyKeyOrContext)) {
|
|
106
|
+
const context = propertyKeyOrContext;
|
|
107
|
+
if (context.kind !== "field" && context.kind !== "accessor") return;
|
|
108
|
+
const key = String(context.name);
|
|
109
|
+
context.addInitializer?.(function (this: object) {
|
|
110
|
+
const ctor = constructorFromTarget(this);
|
|
111
|
+
pushRelation(ctor, { kind: "belongsTo", related, foreignKey, propertyKey: key });
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const key = String(propertyKeyOrContext);
|
|
116
|
+
const ctor = constructorFromTarget(targetOrInitialValue);
|
|
117
|
+
pushRelation(ctor, { kind: "belongsTo", related, foreignKey, propertyKey: key });
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function HasOne(related: () => ModelConstructor, foreignKey: string) {
|
|
122
|
+
return (targetOrInitialValue: object | undefined, propertyKeyOrContext: string | symbol | StandardDecoratorContext): void => {
|
|
123
|
+
if (isStandardDecoratorContext(propertyKeyOrContext)) {
|
|
124
|
+
const context = propertyKeyOrContext;
|
|
125
|
+
if (context.kind !== "field" && context.kind !== "accessor") return;
|
|
126
|
+
const key = String(context.name);
|
|
127
|
+
context.addInitializer?.(function (this: object) {
|
|
128
|
+
const ctor = constructorFromTarget(this);
|
|
129
|
+
pushRelation(ctor, { kind: "hasOne", related, foreignKey, propertyKey: key });
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const key = String(propertyKeyOrContext);
|
|
134
|
+
const ctor = constructorFromTarget(targetOrInitialValue);
|
|
135
|
+
pushRelation(ctor, { kind: "hasOne", related, foreignKey, propertyKey: key });
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function BelongsToMany(related: () => ModelConstructor, pivotTable: string) {
|
|
140
|
+
return (targetOrInitialValue: object | undefined, propertyKeyOrContext: string | symbol | StandardDecoratorContext): void => {
|
|
141
|
+
if (isStandardDecoratorContext(propertyKeyOrContext)) {
|
|
142
|
+
const context = propertyKeyOrContext;
|
|
143
|
+
if (context.kind !== "field" && context.kind !== "accessor") return;
|
|
144
|
+
const key = String(context.name);
|
|
145
|
+
context.addInitializer?.(function (this: object) {
|
|
146
|
+
const ctor = constructorFromTarget(this);
|
|
147
|
+
pushRelation(ctor, { kind: "belongsToMany", related, pivotTable, propertyKey: key });
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const key = String(propertyKeyOrContext);
|
|
152
|
+
const ctor = constructorFromTarget(targetOrInitialValue);
|
|
153
|
+
pushRelation(ctor, { kind: "belongsToMany", related, pivotTable, propertyKey: key });
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function Column(options: ColumnOptions = {}) {
|
|
158
|
+
return (targetOrInitialValue: object | undefined, propertyKeyOrContext: string | symbol | StandardDecoratorContext): void => {
|
|
159
|
+
if (isStandardDecoratorContext(propertyKeyOrContext)) {
|
|
160
|
+
const context = propertyKeyOrContext;
|
|
161
|
+
if (context.kind !== "field" && context.kind !== "accessor") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const key = String(context.name);
|
|
166
|
+
context.addInitializer?.(function (this: object) {
|
|
167
|
+
const ctor = constructorFromTarget(this);
|
|
168
|
+
ensureColumnMap(ctor).set(key, options);
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const key = String(propertyKeyOrContext);
|
|
174
|
+
const ctor = constructorFromTarget(targetOrInitialValue);
|
|
175
|
+
ensureColumnMap(ctor).set(key, options);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getColumnOptions(constructor: object, propertyKey: string): ColumnOptions | undefined {
|
|
180
|
+
let current: object | null = constructor;
|
|
181
|
+
while (current) {
|
|
182
|
+
const map = (current as WithColumns).__xacosColumns;
|
|
183
|
+
const hit = map?.get(propertyKey);
|
|
184
|
+
if (hit) return hit;
|
|
185
|
+
current = Object.getPrototypeOf(current);
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getRelations(ctor: object): RelationMeta[] {
|
|
191
|
+
const collected: RelationMeta[] = [];
|
|
192
|
+
let current: object | null = ctor;
|
|
193
|
+
while (current) {
|
|
194
|
+
const own = (current as WithRelations).__xacosRelations ?? [];
|
|
195
|
+
collected.push(...own);
|
|
196
|
+
current = Object.getPrototypeOf(current);
|
|
197
|
+
}
|
|
198
|
+
return collected;
|
|
199
|
+
}
|
|
200
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { Factory } from './Factory';
|
|
3
|
+
|
|
4
|
+
describe('Factory', () => {
|
|
5
|
+
it('builds N plain objects', async () => {
|
|
6
|
+
const items = await Factory.times(3).build(() => ({
|
|
7
|
+
name: Factory.faker.person.fullName(),
|
|
8
|
+
}));
|
|
9
|
+
expect(items).toHaveLength(3);
|
|
10
|
+
expect(typeof items[0]?.name).toBe('string');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('times(0) returns empty array', async () => {
|
|
14
|
+
const items = await Factory.times(0).build(() => ({ x: 1 }));
|
|
15
|
+
expect(items).toHaveLength(0);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|