@tylix/orm 0.5.1 → 0.7.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.
@@ -1,4 +1,91 @@
1
- const SUPPORTED_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
1
+ const COMPARISON_OPERATORS = ["=", "!=", "<>", ">", "<", ">=", "<="];
2
+ const NULL_OPERATORS = ["IS NULL", "IS NOT NULL"];
3
+ const LIST_OPERATORS = ["IN", "NOT IN"];
4
+
5
+ // "like" and other lowercase spellings are accepted and normalized first.
6
+ const SUPPORTED_OPERATORS = [
7
+ ...COMPARISON_OPERATORS,
8
+ "LIKE",
9
+ ...NULL_OPERATORS,
10
+ ...LIST_OPERATORS,
11
+ ];
12
+
13
+ // What a LIKE condition is written as in SQL. MySQL and SQLite are already
14
+ // case-insensitive for LIKE, so they keep "LIKE"; PostgreSQL is
15
+ // case-sensitive, so its adapter asks for "ILIKE" to behave the same way.
16
+ const LIKE_OPERATORS = ["LIKE", "ILIKE"];
17
+
18
+ // Each value in an IN list is its own "?" placeholder, and every driver caps
19
+ // the number of placeholders per statement.
20
+ const MAX_LIST_VALUES = 1000;
21
+
22
+ // Letters, digits and underscores only, with an optional "table.column" form.
23
+ // Rejects spaces, quotes, semicolons, comments and anything else that could
24
+ // change the shape of the query.
25
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
26
+
27
+ function assertIdentifier(name, kind) {
28
+ if (typeof name !== "string" || !IDENTIFIER.test(name)) {
29
+ throw new Error(`Invalid ${kind} name "${String(name)}".`);
30
+ }
31
+ return name;
32
+ }
33
+
34
+ function normalizeOperator(operator) {
35
+ return String(operator).trim().replace(/\s+/g, " ").toUpperCase();
36
+ }
37
+
38
+ // Builds one WHERE condition and pushes its values onto `params`.
39
+ function buildCondition(w, likeOperator, params) {
40
+ const operator = normalizeOperator(w.operator);
41
+ if (!SUPPORTED_OPERATORS.includes(operator)) {
42
+ throw new Error(
43
+ `Unsupported where operator "${w.operator}". Supported: ${SUPPORTED_OPERATORS.join(", ")}`
44
+ );
45
+ }
46
+
47
+ const column = assertIdentifier(w.field, "column");
48
+
49
+ if (NULL_OPERATORS.includes(operator)) {
50
+ return `${column} ${operator}`;
51
+ }
52
+
53
+ if (LIST_OPERATORS.includes(operator)) {
54
+ const values = w.value;
55
+ if (!Array.isArray(values)) {
56
+ throw new Error(`Operator "${operator}" needs an array of values.`);
57
+ }
58
+ if (values.length > MAX_LIST_VALUES) {
59
+ throw new Error(
60
+ `Operator "${operator}" accepts at most ${MAX_LIST_VALUES} values, got ${values.length}.`
61
+ );
62
+ }
63
+ // NULL never matches inside IN, and a single NULL makes NOT IN match
64
+ // nothing at all, so reject it instead of returning surprising rows.
65
+ if (values.some((v) => v === null || v === undefined)) {
66
+ throw new Error(
67
+ `"${operator}" lists cannot contain null or undefined. Use whereNull() / whereNotNull() for null checks.`
68
+ );
69
+ }
70
+ // "IN ()" is invalid SQL. An empty IN matches nothing; an empty NOT IN
71
+ // excludes nothing.
72
+ if (values.length === 0) {
73
+ return operator === "IN" ? "1 = 0" : "1 = 1";
74
+ }
75
+ params.push(...values);
76
+ return `${column} ${operator} (${values.map(() => "?").join(", ")})`;
77
+ }
78
+
79
+ // "= NULL" and "!= NULL" are never true in SQL, so translate them.
80
+ if (w.value === null) {
81
+ if (operator === "=") return `${column} IS NULL`;
82
+ if (operator === "!=" || operator === "<>") return `${column} IS NOT NULL`;
83
+ throw new Error(`Operator "${operator}" cannot be used with null.`);
84
+ }
85
+
86
+ params.push(w.value);
87
+ return `${column} ${operator === "LIKE" ? likeOperator : operator} ?`;
88
+ }
2
89
 
3
90
  /**
4
91
  * Translates a structured query descriptor into a "?"-placeholder SQL
@@ -8,29 +95,38 @@ const SUPPORTED_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
8
95
  * translation and datetime conversion correctly per driver, so this
9
96
  * only needs to speak the common "?" dialect.
10
97
  *
98
+ * Values are always passed as parameters. Table and column names cannot be
99
+ * parameters, so they are validated as plain identifiers instead.
100
+ *
11
101
  * limit/offset are inlined as validated integers rather than passed as
12
102
  * placeholders, since mysql2 does not reliably support placeholders in
13
103
  * LIMIT/OFFSET position (same reasoning as Model.paginate()).
104
+ *
105
+ * options.likeOperator: "LIKE" (default) or "ILIKE", used for LIKE conditions.
14
106
  */
15
- export function buildSelectSql(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
107
+ export function buildSelectSql(
108
+ table,
109
+ { wheres = [], orders = [], limitValue, offsetValue } = {},
110
+ { likeOperator = "LIKE" } = {}
111
+ ) {
112
+ if (!LIKE_OPERATORS.includes(likeOperator)) {
113
+ throw new Error(
114
+ `Unsupported likeOperator "${likeOperator}". Use: ${LIKE_OPERATORS.join(", ")}`
115
+ );
116
+ }
117
+
16
118
  const params = [];
17
- let sql = `SELECT * FROM ${table}`;
119
+ let sql = `SELECT * FROM ${assertIdentifier(table, "table")}`;
18
120
 
19
121
  if (wheres.length > 0) {
20
- const clauses = wheres.map((w) => {
21
- if (!SUPPORTED_OPERATORS.includes(w.operator)) {
22
- throw new Error(
23
- `Unsupported where operator "${w.operator}". Supported: ${SUPPORTED_OPERATORS.join(", ")}`
24
- );
25
- }
26
- params.push(w.value);
27
- return `${w.field} ${w.operator} ?`;
28
- });
122
+ const clauses = wheres.map((w) => buildCondition(w, likeOperator, params));
29
123
  sql += ` WHERE ${clauses.join(" AND ")}`;
30
124
  }
31
125
 
32
126
  if (orders.length > 0) {
33
- const clauses = orders.map((o) => `${o.field} ${o.direction}`);
127
+ const clauses = orders.map(
128
+ (o) => `${assertIdentifier(o.field, "column")} ${o.direction}`
129
+ );
34
130
  sql += ` ORDER BY ${clauses.join(", ")}`;
35
131
  }
36
132
 
@@ -47,4 +143,4 @@ export function buildSelectSql(table, { wheres = [], orders = [], limitValue, of
47
143
  }
48
144
 
49
145
  return { sql, params };
50
- }
146
+ }
@@ -1,7 +1,21 @@
1
- // src/query/types.d.ts
1
+ export type ComparisonOperator =
2
+ | '='
3
+ | '!='
4
+ | '<>'
5
+ | '>'
6
+ | '<'
7
+ | '>='
8
+ | '<='
9
+ | 'LIKE'
10
+ | 'like'
11
+
12
+ export type ListOperator = 'IN' | 'NOT IN'
13
+
14
+ export type NullOperator = 'IS NULL' | 'IS NOT NULL'
15
+
2
16
  export interface WhereClause {
3
17
  field: string
4
- operator: '=' | '!=' | '>' | '<' | '>=' | '<='
18
+ operator: ComparisonOperator | ListOperator | NullOperator
5
19
  value: unknown
6
20
  }
7
21
 
@@ -15,4 +29,28 @@ export interface QueryDescriptor {
15
29
  orders: OrderClause[]
16
30
  limitValue?: number
17
31
  offsetValue?: number
32
+ }
33
+
34
+ export interface ColumnBuilder {
35
+ unique(): void;
36
+ }
37
+
38
+ export interface TableBuilder {
39
+ increments(name: string): ColumnBuilder;
40
+ string(name: string): ColumnBuilder;
41
+ text(name: string): ColumnBuilder;
42
+ boolean(name: string): ColumnBuilder;
43
+ integer(name: string): ColumnBuilder;
44
+ date(name: string): ColumnBuilder;
45
+ datetime(name: string): ColumnBuilder;
46
+ json(name: string): ColumnBuilder;
47
+ timestamps(): void;
48
+ // present only in alterTable's callback, absent from createTable's
49
+ dropColumn?(name: string): void;
50
+ }
51
+
52
+ export interface Schema {
53
+ createTable(tableName: string, callback: (table: TableBuilder) => void | Promise<void>): Promise<void>;
54
+ dropTable(tableName: string): Promise<void>;
55
+ alterTable(tableName: string, callback: (table: TableBuilder) => void | Promise<void>): Promise<void>;
18
56
  }
@@ -1,35 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { createAdapter } from "./AdapterFactory.js";
4
- import { SqliteAdapter } from "./SqliteAdapter.js";
5
- import { PostgresAdapter } from "./PostgresAdapter.js";
6
- import { MysqlAdapter } from "./MysqlAdapter.js";
7
- import { MongoAdapter } from "./MongoAdapter.js";
8
-
9
- test("creates a SqliteAdapter for driver: sqlite", () => {
10
- const adapter = createAdapter({ driver: "sqlite", filename: "test.sqlite" });
11
- assert.ok(adapter instanceof SqliteAdapter);
12
- assert.equal(adapter.filename, "test.sqlite");
13
- });
14
-
15
- test("throws on unknown driver", () => {
16
- assert.throws(
17
- () => createAdapter({ driver: "oracle" }),
18
- /Unknown database driver "oracle"/
19
- );
20
- });
21
-
22
- test("creates a PostgresAdapter for driver: postgres", () => {
23
- const adapter = createAdapter({ driver: "postgres", database: "test" });
24
- assert.ok(adapter instanceof PostgresAdapter);
25
- });
26
-
27
- test("creates a MysqlAdapter for driver: mysql", () => {
28
- const adapter = createAdapter({ driver: "mysql", database: "test" });
29
- assert.ok(adapter instanceof MysqlAdapter);
30
- });
31
-
32
- test("creates a MongoAdapter for driver: mongodb", () => {
33
- const adapter = createAdapter({ driver: "mongodb", database: "test" });
34
- assert.ok(adapter instanceof MongoAdapter);
35
- });
@@ -1,52 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { parseQuery, MongoAdapter } from "./MongoAdapter.js";
4
-
5
- test("recognizes SELECT * FROM table", () => {
6
- assert.deepEqual(parseQuery("SELECT * FROM posts"), { type: "findAll", table: "posts" });
7
- });
8
-
9
- test("recognizes SELECT * FROM table WHERE field = ?", () => {
10
- assert.deepEqual(parseQuery("SELECT * FROM posts WHERE id = ?"), {
11
- type: "findOne",
12
- table: "posts",
13
- field: "id",
14
- });
15
- });
16
-
17
- test("recognizes INSERT INTO table (cols) VALUES (?...)", () => {
18
- const result = parseQuery("INSERT INTO posts (title, body) VALUES (?, ?)");
19
- assert.equal(result.type, "insert");
20
- assert.equal(result.table, "posts");
21
- assert.deepEqual(result.columns, ["title", "body"]);
22
- });
23
-
24
- test("recognizes UPDATE table SET col=?,col=? WHERE field = ?", () => {
25
- const result = parseQuery("UPDATE posts SET title = ?, body = ? WHERE id = ?");
26
- assert.equal(result.type, "update");
27
- assert.deepEqual(result.columns, ["title", "body"]);
28
- assert.equal(result.whereField, "id");
29
- });
30
-
31
- test("recognizes DELETE FROM table WHERE field = ?", () => {
32
- assert.deepEqual(parseQuery("DELETE FROM posts WHERE id = ?"), {
33
- type: "delete",
34
- table: "posts",
35
- field: "id",
36
- });
37
- });
38
-
39
- test("recognizes CREATE TABLE IF NOT EXISTS as a no-op shape", () => {
40
- const result = parseQuery("CREATE TABLE IF NOT EXISTS posts (id INT)");
41
- assert.equal(result.type, "createTable");
42
- assert.equal(result.table, "posts");
43
- });
44
-
45
- test("throws clearly on an unrecognized query shape", () => {
46
- assert.throws(() => parseQuery("SELECT COUNT(*) FROM posts"), /does not recognize/);
47
- });
48
-
49
- test("ensureConnected throws before connect() is called", async () => {
50
- const adapter = new MongoAdapter({ database: "test" });
51
- await assert.rejects(() => adapter.all("SELECT * FROM posts"));
52
- });
@@ -1,36 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { MysqlAdapter } from "./MysqlAdapter.js";
4
-
5
- test("columnType maps logical types to MySQL SQL types", () => {
6
- const adapter = new MysqlAdapter({ database: "test" });
7
- assert.equal(adapter.columnType("increments"), "INT AUTO_INCREMENT PRIMARY KEY");
8
- assert.equal(adapter.columnType("string"), "VARCHAR(255)");
9
- assert.equal(adapter.columnType("boolean"), "TINYINT(1)");
10
- assert.equal(adapter.columnType("json"), "JSON");
11
- });
12
-
13
- test("columnType throws on unknown logical type", () => {
14
- const adapter = new MysqlAdapter({ database: "test" });
15
- assert.throws(() => adapter.columnType("nonsense"), /no mapping/);
16
- });
17
-
18
- test("ensureConnected throws before connect() is called", async () => {
19
- const adapter = new MysqlAdapter({ database: "test" });
20
- await assert.rejects(() => adapter.all("SELECT 1"));
21
- });
22
-
23
- test("toMysqlDateTime converts ISO 8601 strings to MySQL DATETIME format", async () => {
24
- const { toMysqlDateTime } = await import("./MysqlAdapter.js");
25
- assert.equal(
26
- toMysqlDateTime("2026-07-14T03:35:14.083Z"),
27
- "2026-07-14 03:35:14"
28
- );
29
- });
30
-
31
- test("toMysqlDateTime leaves non-ISO-datetime values untouched", async () => {
32
- const { toMysqlDateTime } = await import("./MysqlAdapter.js");
33
- assert.equal(toMysqlDateTime("Hello Tylix"), "Hello Tylix");
34
- assert.equal(toMysqlDateTime(42), 42);
35
- assert.equal(toMysqlDateTime(true), true);
36
- });
@@ -1,47 +0,0 @@
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
- });
@@ -1,39 +0,0 @@
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
- });
@@ -1,60 +0,0 @@
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
- });
@@ -1,74 +0,0 @@
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
- });
@@ -1,86 +0,0 @@
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
- });