@tylix/orm 0.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.
@@ -0,0 +1,113 @@
1
+ import pg from 'pg'
2
+ import { DatabaseAdapter } from './DatabaseAdapter.js'
3
+
4
+ const COLUMN_TYPES = {
5
+ increments: 'SERIAL PRIMARY KEY',
6
+ string: 'TEXT',
7
+ text: 'TEXT',
8
+ boolean: 'BOOLEAN',
9
+ integer: 'INTEGER',
10
+ date: 'DATE',
11
+ datetime: 'TIMESTAMP',
12
+ json: 'JSONB',
13
+ timestamp: 'TIMESTAMP',
14
+ }
15
+
16
+ export function toPositionalPlaceholders(sql) {
17
+ let index = 0
18
+ return sql.replace(/\?/g, () => `$${++index}`)
19
+ }
20
+
21
+ export function ensureReturningId(sql) {
22
+ const isInsert = /^\s*INSERT\s+INTO/i.test(sql)
23
+ const hasReturning = /RETURNING/i.test(sql)
24
+ if (isInsert && !hasReturning) {
25
+ return `${sql.trim().replace(/;$/, '')} RETURNING id`
26
+ }
27
+ return sql
28
+ }
29
+
30
+ export class PostgresAdapter extends DatabaseAdapter {
31
+ constructor({ connectionString, host, port, user, password, database } = {}) {
32
+ super()
33
+ this.config = connectionString
34
+ ? { connectionString }
35
+ : { host, port, user, password, database }
36
+ this.pool = null
37
+ }
38
+
39
+ async connect() {
40
+ this.pool = new pg.Pool(this.config)
41
+ return this
42
+ }
43
+
44
+ async close() {
45
+ if (this.pool) {
46
+ await this.pool.end()
47
+ this.pool = null
48
+ }
49
+ }
50
+
51
+ ensureConnected() {
52
+ if (!this.pool) {
53
+ throw new Error('PostgresAdapter is not connected. Call connect() first.')
54
+ }
55
+ }
56
+
57
+ async run(sql, params = []) {
58
+ this.ensureConnected()
59
+ const finalSql = toPositionalPlaceholders(ensureReturningId(sql))
60
+ const result = await this.pool.query(finalSql, params)
61
+ const lastInsertRowid = result.rows[0]?.id
62
+ return { ...result, lastInsertRowid }
63
+ }
64
+
65
+ async get(sql, params = []) {
66
+ this.ensureConnected()
67
+ const finalSql = toPositionalPlaceholders(sql)
68
+ const result = await this.pool.query(finalSql, params)
69
+ return result.rows[0] ?? null
70
+ }
71
+
72
+ async all(sql, params = []) {
73
+ this.ensureConnected()
74
+ const finalSql = toPositionalPlaceholders(sql)
75
+ const result = await this.pool.query(finalSql, params)
76
+ return result.rows
77
+ }
78
+
79
+ async count(table) {
80
+ this.ensureConnected()
81
+ const result = await this.pool.query(
82
+ `SELECT COUNT(*) as count FROM ${table}`,
83
+ )
84
+ return Number(result.rows[0].count)
85
+ }
86
+
87
+ async paginate(table, limit, offset) {
88
+ this.ensureConnected()
89
+ const result = await this.pool.query(
90
+ `SELECT * FROM ${table} ORDER BY id ASC LIMIT $1 OFFSET $2`,
91
+ [limit, offset],
92
+ )
93
+ return result.rows
94
+ }
95
+
96
+ async listTables() {
97
+ this.ensureConnected()
98
+ const result = await this.pool.query(
99
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
100
+ )
101
+ return result.rows.map((r) => r.tablename)
102
+ }
103
+
104
+ columnType(logicalType) {
105
+ const sql = COLUMN_TYPES[logicalType]
106
+ if (!sql) {
107
+ throw new Error(
108
+ `PostgresAdapter has no mapping for column type "${logicalType}"`,
109
+ )
110
+ }
111
+ return sql
112
+ }
113
+ }
@@ -0,0 +1,47 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { PostgresAdapter, toPositionalPlaceholders, ensureReturningId } from "./PostgresAdapter.js";
4
+
5
+ test("toPositionalPlaceholders converts ? to $1, $2, $3...", () => {
6
+ const sql = "INSERT INTO posts (title, body) VALUES (?, ?)";
7
+ assert.equal(
8
+ toPositionalPlaceholders(sql),
9
+ "INSERT INTO posts (title, body) VALUES ($1, $2)"
10
+ );
11
+ });
12
+
13
+ test("toPositionalPlaceholders handles a single placeholder", () => {
14
+ assert.equal(toPositionalPlaceholders("SELECT * FROM posts WHERE id = ?"), "SELECT * FROM posts WHERE id = $1");
15
+ });
16
+
17
+ test("ensureReturningId appends RETURNING id to bare INSERT statements", () => {
18
+ const sql = "INSERT INTO posts (title) VALUES (?)";
19
+ assert.equal(ensureReturningId(sql), "INSERT INTO posts (title) VALUES (?) RETURNING id");
20
+ });
21
+
22
+ test("ensureReturningId does not duplicate an existing RETURNING clause", () => {
23
+ const sql = "INSERT INTO posts (title) VALUES (?) RETURNING id";
24
+ assert.equal(ensureReturningId(sql), sql);
25
+ });
26
+
27
+ test("ensureReturningId leaves non-INSERT statements untouched", () => {
28
+ const sql = "UPDATE posts SET title = ? WHERE id = ?";
29
+ assert.equal(ensureReturningId(sql), sql);
30
+ });
31
+
32
+ test("columnType maps logical types to Postgres SQL types", () => {
33
+ const adapter = new PostgresAdapter({ database: "test" });
34
+ assert.equal(adapter.columnType("increments"), "SERIAL PRIMARY KEY");
35
+ assert.equal(adapter.columnType("boolean"), "BOOLEAN");
36
+ assert.equal(adapter.columnType("json"), "JSONB");
37
+ });
38
+
39
+ test("columnType throws on unknown logical type", () => {
40
+ const adapter = new PostgresAdapter({ database: "test" });
41
+ assert.throws(() => adapter.columnType("nonsense"), /no mapping/);
42
+ });
43
+
44
+ test("ensureConnected throws before connect() is called", async () => {
45
+ const adapter = new PostgresAdapter({ database: "test" });
46
+ await assert.rejects(() => adapter.all("SELECT 1"));
47
+ });
@@ -0,0 +1,92 @@
1
+ import { DatabaseAdapter } from './DatabaseAdapter.js'
2
+
3
+ const COLUMN_TYPES = {
4
+ increments: 'INTEGER PRIMARY KEY AUTOINCREMENT',
5
+ string: 'TEXT',
6
+ text: 'TEXT',
7
+ boolean: 'INTEGER',
8
+ integer: 'INTEGER',
9
+ date: 'TEXT',
10
+ datetime: 'TEXT',
11
+ json: 'TEXT',
12
+ timestamp: 'TEXT',
13
+ }
14
+
15
+ export class SqliteAdapter extends DatabaseAdapter {
16
+ constructor({ filename = 'database.sqlite' } = {}) {
17
+ super()
18
+ this.filename = filename
19
+ this.db = null
20
+ }
21
+
22
+ async connect() {
23
+ const { DatabaseSync } = await import('node:sqlite')
24
+ this.db = new DatabaseSync(this.filename)
25
+ return this
26
+ }
27
+
28
+ async close() {
29
+ if (this.db) {
30
+ this.db.close()
31
+ this.db = null
32
+ }
33
+ }
34
+
35
+ ensureConnected() {
36
+ if (!this.db) {
37
+ throw new Error('SqliteAdapter is not connected. Call connect() first.')
38
+ }
39
+ }
40
+
41
+ async run(sql, params = []) {
42
+ this.ensureConnected()
43
+ const stmt = this.db.prepare(sql)
44
+ return stmt.run(...params)
45
+ }
46
+
47
+ async get(sql, params = []) {
48
+ this.ensureConnected()
49
+ const stmt = this.db.prepare(sql)
50
+ return stmt.get(...params) ?? null
51
+ }
52
+
53
+ async all(sql, params = []) {
54
+ this.ensureConnected()
55
+ const stmt = this.db.prepare(sql)
56
+ return stmt.all(...params)
57
+ }
58
+
59
+ async count(table) {
60
+ this.ensureConnected()
61
+ const row = this.db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()
62
+ return row.count
63
+ }
64
+
65
+ async paginate(table, limit, offset) {
66
+ this.ensureConnected()
67
+ const stmt = this.db.prepare(
68
+ `SELECT * FROM ${table} ORDER BY id ASC LIMIT ? OFFSET ?`,
69
+ )
70
+ return stmt.all(limit, offset)
71
+ }
72
+
73
+ async listTables() {
74
+ this.ensureConnected()
75
+ const rows = this.db
76
+ .prepare(
77
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
78
+ )
79
+ .all()
80
+ return rows.map((r) => r.name)
81
+ }
82
+
83
+ columnType(logicalType) {
84
+ const sql = COLUMN_TYPES[logicalType]
85
+ if (!sql) {
86
+ throw new Error(
87
+ `SqliteAdapter has no mapping for column type "${logicalType}"`,
88
+ )
89
+ }
90
+ return sql
91
+ }
92
+ }
@@ -0,0 +1,39 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { SqliteAdapter } from "./SqliteAdapter.js";
7
+
8
+ test("connects, creates a table, inserts, and queries rows", async () => {
9
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-orm-test-"));
10
+ const dbFile = path.join(tmpDir, "test.sqlite");
11
+
12
+ const adapter = new SqliteAdapter({ filename: dbFile });
13
+ await adapter.connect();
14
+
15
+ await adapter.run(`
16
+ CREATE TABLE posts (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ title TEXT NOT NULL
19
+ )
20
+ `);
21
+
22
+ await adapter.run("INSERT INTO posts (title) VALUES (?)", ["Hello Tylix"]);
23
+ await adapter.run("INSERT INTO posts (title) VALUES (?)", ["Second Post"]);
24
+
25
+ const all = await adapter.all("SELECT * FROM posts");
26
+ assert.equal(all.length, 2);
27
+ assert.equal(all[0].title, "Hello Tylix");
28
+
29
+ const one = await adapter.get("SELECT * FROM posts WHERE id = ?", [1]);
30
+ assert.equal(one.title, "Hello Tylix");
31
+
32
+ await adapter.close();
33
+ await fs.rm(tmpDir, { recursive: true, force: true });
34
+ });
35
+
36
+ test("ensureConnected throws if used before connect()", async () => {
37
+ const adapter = new SqliteAdapter({ filename: ":memory:" });
38
+ await assert.rejects(() => adapter.all("SELECT 1"));
39
+ });
@@ -0,0 +1,60 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { SqliteAdapter } from "./SqliteAdapter.js";
7
+
8
+ let tmpDir;
9
+ let adapter;
10
+
11
+ beforeEach(async () => {
12
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-query-test-"));
13
+ adapter = new SqliteAdapter({ filename: path.join(tmpDir, "test.sqlite") });
14
+ await adapter.connect();
15
+ await adapter.run(`
16
+ CREATE TABLE posts (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ title TEXT,
19
+ views INTEGER,
20
+ status TEXT
21
+ )
22
+ `);
23
+ await adapter.run("INSERT INTO posts (title, views, status) VALUES (?, ?, ?)", ["A", 50, "draft"]);
24
+ await adapter.run("INSERT INTO posts (title, views, status) VALUES (?, ?, ?)", ["B", 150, "published"]);
25
+ await adapter.run("INSERT INTO posts (title, views, status) VALUES (?, ?, ?)", ["C", 300, "published"]);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await adapter.close();
30
+ await fs.rm(tmpDir, { recursive: true, force: true });
31
+ });
32
+
33
+ test("query() with a where filter", async () => {
34
+ const rows = await adapter.query("posts", {
35
+ wheres: [{ field: "status", operator: "=", value: "published" }],
36
+ });
37
+ assert.equal(rows.length, 2);
38
+ });
39
+
40
+ test("query() with where + orderBy + limit combined", async () => {
41
+ const rows = await adapter.query("posts", {
42
+ wheres: [{ field: "status", operator: "=", value: "published" }],
43
+ orders: [{ field: "views", direction: "DESC" }],
44
+ limitValue: 1,
45
+ });
46
+ assert.equal(rows.length, 1);
47
+ assert.equal(rows[0].title, "C");
48
+ });
49
+
50
+ test("query() with a > comparison operator", async () => {
51
+ const rows = await adapter.query("posts", {
52
+ wheres: [{ field: "views", operator: ">", value: 100 }],
53
+ });
54
+ assert.equal(rows.length, 2);
55
+ });
56
+
57
+ test("query() with no descriptor returns all rows", async () => {
58
+ const rows = await adapter.query("posts", {});
59
+ assert.equal(rows.length, 3);
60
+ });
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./adapters/DatabaseAdapter.js";
2
+ export * from "./adapters/SqliteAdapter.js";
3
+ export * from "./model/ConnectionManager.js";
4
+ export * from "./model/Model.js";
5
+ export * from "./adapters/AdapterFactory.js";
6
+ export * from "./adapters/MysqlAdapter.js";
7
+ export * from "./adapters/MongoAdapter.js";
8
+ export * from "./query/QueryBuilder.js";
9
+ export * from "./query/buildSelectSql.js";
@@ -0,0 +1,20 @@
1
+ let activeAdapter = null;
2
+
3
+ export const ConnectionManager = {
4
+ setAdapter(adapter) {
5
+ activeAdapter = adapter;
6
+ },
7
+
8
+ getAdapter() {
9
+ if (!activeAdapter) {
10
+ throw new Error(
11
+ "No database adapter configured. Call ConnectionManager.setAdapter() before using models."
12
+ );
13
+ }
14
+ return activeAdapter;
15
+ },
16
+
17
+ reset() {
18
+ activeAdapter = null;
19
+ },
20
+ };
@@ -0,0 +1,141 @@
1
+ import { ConnectionManager } from './ConnectionManager.js'
2
+ import { QueryBuilder } from '../query/QueryBuilder.js'
3
+
4
+ export class Model {
5
+ static table = null
6
+ static fillable = []
7
+ static timestamps = true
8
+
9
+ static getTable() {
10
+ if (!this.table) {
11
+ throw new Error(`${this.name} must define a static "table" property.`)
12
+ }
13
+ return this.table
14
+ }
15
+
16
+ static getAdapter() {
17
+ return ConnectionManager.getAdapter()
18
+ }
19
+
20
+ static async all() {
21
+ const adapter = ConnectionManager.getAdapter()
22
+ return adapter.all(`SELECT * FROM ${this.getTable()}`)
23
+ }
24
+
25
+ /**
26
+ * Returns a QueryBuilder for fluent filtering:
27
+ * await Post.query().where("status", "published").orderBy("created_at", "DESC").get()
28
+ */
29
+ static query() {
30
+ return new QueryBuilder(this)
31
+ }
32
+
33
+ static async paginate({ page = 1, limit = 20 } = {}) {
34
+ const adapter = ConnectionManager.getAdapter()
35
+ const safePage = Math.max(1, Math.floor(page))
36
+ const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)))
37
+ const offset = (safePage - 1) * safeLimit
38
+
39
+ const total = await adapter.count(this.getTable())
40
+ const data = await adapter.paginate(this.getTable(), safeLimit, offset)
41
+
42
+ return {
43
+ data,
44
+ meta: {
45
+ page: safePage,
46
+ limit: safeLimit,
47
+ total,
48
+ totalPages: Math.ceil(total / safeLimit),
49
+ },
50
+ }
51
+ }
52
+
53
+ static async find(id) {
54
+ const adapter = ConnectionManager.getAdapter()
55
+ return adapter.get(`SELECT * FROM ${this.getTable()} WHERE id = ?`, [id])
56
+ }
57
+
58
+ static async create(data) {
59
+ const adapter = ConnectionManager.getAdapter()
60
+ const fields = this.fillable.filter((f) => f in data)
61
+
62
+ if (fields.length === 0) {
63
+ throw new Error(`No fillable fields provided for ${this.name}.create()`)
64
+ }
65
+
66
+ const values = { ...Object.fromEntries(fields.map((f) => [f, data[f]])) }
67
+
68
+ if (this.timestamps) {
69
+ const now = new Date().toISOString()
70
+ values.created_at = now
71
+ values.updated_at = now
72
+ }
73
+
74
+ const columns = Object.keys(values)
75
+ const placeholders = columns.map(() => '?').join(', ')
76
+ const columnValues = Object.values(values)
77
+
78
+ const result = await adapter.run(
79
+ `INSERT INTO ${this.getTable()} (${columns.join(', ')}) VALUES (${placeholders})`,
80
+ columnValues,
81
+ )
82
+
83
+ return this.find(result.lastInsertRowid)
84
+ }
85
+
86
+ static async update(id, data) {
87
+ const adapter = ConnectionManager.getAdapter()
88
+ const fields = this.fillable.filter((f) => f in data)
89
+
90
+ if (fields.length === 0) {
91
+ throw new Error(`No fillable fields provided for ${this.name}.update()`)
92
+ }
93
+
94
+ const values = { ...Object.fromEntries(fields.map((f) => [f, data[f]])) }
95
+
96
+ if (this.timestamps) {
97
+ values.updated_at = new Date().toISOString()
98
+ }
99
+
100
+ const setClause = Object.keys(values)
101
+ .map((f) => `${f} = ?`)
102
+ .join(', ')
103
+ const columnValues = Object.values(values)
104
+
105
+ await adapter.run(
106
+ `UPDATE ${this.getTable()} SET ${setClause} WHERE id = ?`,
107
+ [...columnValues, id],
108
+ )
109
+
110
+ return this.find(id)
111
+ }
112
+
113
+ static async delete(id) {
114
+ const adapter = ConnectionManager.getAdapter()
115
+ await adapter.run(`DELETE FROM ${this.getTable()} WHERE id = ?`, [id])
116
+ return true
117
+ }
118
+
119
+ static async belongsTo(row, foreignKey, RelatedModel) {
120
+ if (!row || row[foreignKey] === undefined || row[foreignKey] === null) {
121
+ return null
122
+ }
123
+ return RelatedModel.find(row[foreignKey])
124
+ }
125
+
126
+ static async hasMany(row, RelatedModel, foreignKey) {
127
+ const adapter = ConnectionManager.getAdapter()
128
+ return adapter.all(
129
+ `SELECT * FROM ${RelatedModel.getTable()} WHERE ${foreignKey} = ?`,
130
+ [row.id],
131
+ )
132
+ }
133
+
134
+ static async first() {
135
+ return this.query().first()
136
+ }
137
+
138
+ static async count() {
139
+ return this.query().count()
140
+ }
141
+ }
@@ -0,0 +1,74 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { SqliteAdapter } from "../adapters/SqliteAdapter.js";
7
+ import { ConnectionManager } from "./ConnectionManager.js";
8
+ import { Model } from "./Model.js";
9
+
10
+ class Post extends Model {
11
+ static table = "posts";
12
+ static fillable = ["title"];
13
+ }
14
+
15
+ let tmpDir;
16
+ let adapter;
17
+
18
+ beforeEach(async () => {
19
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-paginate-test-"));
20
+ adapter = new SqliteAdapter({ filename: path.join(tmpDir, "test.sqlite") });
21
+ await adapter.connect();
22
+ await adapter.run(`
23
+ CREATE TABLE posts (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ title TEXT,
26
+ created_at TEXT,
27
+ updated_at TEXT
28
+ )
29
+ `);
30
+ ConnectionManager.setAdapter(adapter);
31
+
32
+ for (let i = 1; i <= 25; i++) {
33
+ await Post.create({ title: `Post ${i}` });
34
+ }
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await adapter.close();
39
+ await fs.rm(tmpDir, { recursive: true, force: true });
40
+ ConnectionManager.reset();
41
+ });
42
+
43
+ test("paginate() defaults to page 1, limit 20", async () => {
44
+ const result = await Post.paginate();
45
+ assert.equal(result.data.length, 20);
46
+ assert.equal(result.data[0].title, "Post 1");
47
+ assert.equal(result.meta.page, 1);
48
+ assert.equal(result.meta.limit, 20);
49
+ assert.equal(result.meta.total, 25);
50
+ assert.equal(result.meta.totalPages, 2);
51
+ });
52
+
53
+ test("paginate() returns the second page correctly", async () => {
54
+ const result = await Post.paginate({ page: 2, limit: 20 });
55
+ assert.equal(result.data.length, 5);
56
+ assert.equal(result.data[0].title, "Post 21");
57
+ });
58
+
59
+ test("paginate() respects a custom limit", async () => {
60
+ const result = await Post.paginate({ page: 1, limit: 10 });
61
+ assert.equal(result.data.length, 10);
62
+ assert.equal(result.meta.totalPages, 3);
63
+ });
64
+
65
+ test("paginate() clamps limit to a maximum of 100", async () => {
66
+ const result = await Post.paginate({ page: 1, limit: 9999 });
67
+ assert.equal(result.meta.limit, 100);
68
+ });
69
+
70
+ test("paginate() clamps page to a minimum of 1", async () => {
71
+ const result = await Post.paginate({ page: -5, limit: 20 });
72
+ assert.equal(result.meta.page, 1);
73
+ assert.equal(result.data[0].title, "Post 1");
74
+ });
@@ -0,0 +1,86 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { SqliteAdapter } from "../adapters/SqliteAdapter.js";
7
+ import { ConnectionManager } from "./ConnectionManager.js";
8
+ import { Model } from "./Model.js";
9
+
10
+ class Post extends Model {
11
+ static table = "posts";
12
+ static fillable = ["title"];
13
+ }
14
+
15
+ class Comment extends Model {
16
+ static table = "comments";
17
+ static fillable = ["content", "post_id"];
18
+ }
19
+
20
+ let tmpDir;
21
+ let adapter;
22
+
23
+ beforeEach(async () => {
24
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-relations-test-"));
25
+ adapter = new SqliteAdapter({ filename: path.join(tmpDir, "test.sqlite") });
26
+ await adapter.connect();
27
+
28
+ await adapter.run(`
29
+ CREATE TABLE posts (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ title TEXT,
32
+ created_at TEXT,
33
+ updated_at TEXT
34
+ )
35
+ `);
36
+
37
+ await adapter.run(`
38
+ CREATE TABLE comments (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ content TEXT,
41
+ post_id INTEGER,
42
+ created_at TEXT,
43
+ updated_at TEXT
44
+ )
45
+ `);
46
+
47
+ ConnectionManager.setAdapter(adapter);
48
+ });
49
+
50
+ afterEach(async () => {
51
+ await adapter.close();
52
+ await fs.rm(tmpDir, { recursive: true, force: true });
53
+ ConnectionManager.reset();
54
+ });
55
+
56
+ test("belongsTo() loads the parent row via foreign key", async () => {
57
+ const post = await Post.create({ title: "Hello" });
58
+ const comment = await Comment.create({ content: "Nice post!", post_id: post.id });
59
+
60
+ const loadedPost = await Comment.belongsTo(comment, "post_id", Post);
61
+ assert.equal(loadedPost.title, "Hello");
62
+ });
63
+
64
+ test("belongsTo() returns null when foreign key is missing", async () => {
65
+ const orphan = { content: "no post", post_id: null };
66
+ const result = await Comment.belongsTo(orphan, "post_id", Post);
67
+ assert.equal(result, null);
68
+ });
69
+
70
+ test("hasMany() loads all child rows for a parent", async () => {
71
+ const post = await Post.create({ title: "Popular Post" });
72
+ await Comment.create({ content: "First!", post_id: post.id });
73
+ await Comment.create({ content: "Second!", post_id: post.id });
74
+ await Comment.create({ content: "Unrelated", post_id: 9999 });
75
+
76
+ const comments = await Post.hasMany(post, Comment, "post_id");
77
+ assert.equal(comments.length, 2);
78
+ assert.equal(comments[0].content, "First!");
79
+ assert.equal(comments[1].content, "Second!");
80
+ });
81
+
82
+ test("hasMany() returns an empty array when there are no children", async () => {
83
+ const post = await Post.create({ title: "Lonely Post" });
84
+ const comments = await Post.hasMany(post, Comment, "post_id");
85
+ assert.deepEqual(comments, []);
86
+ });