@tylix/orm 0.6.0 → 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 +40 -15
- package/package.json +33 -6
- package/src/adapters/DatabaseAdapter.d.ts +6 -0
- package/src/adapters/DatabaseAdapter.js +20 -0
- package/src/adapters/MongoAdapter.js +11 -0
- package/src/adapters/MysqlAdapter.js +6 -0
- package/src/adapters/PostgresAdapter.js +8 -1
- package/src/adapters/SqliteAdapter.js +54 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -0
- package/src/instrumentation.d.ts +10 -0
- package/src/instrumentation.js +26 -0
- package/src/model/Model.d.ts +21 -0
- package/src/model/Model.js +39 -4
- package/src/model/observers.js +83 -0
- package/src/model/traits.d.ts +30 -0
- package/src/model/traits.js +61 -0
- package/src/adapters/AdapterFactory.test.js +0 -35
- package/src/adapters/MongoAdapter.test.js +0 -52
- package/src/adapters/MysqlAdapter.test.js +0 -36
- package/src/adapters/PostgresAdapter.test.js +0 -47
- package/src/adapters/SqliteAdapter.test.js +0 -39
- package/src/adapters/query.test.js +0 -60
- package/src/model/Model.paginate.test.js +0 -74
- package/src/model/Model.relations.test.js +0 -86
- package/src/model/Model.test.js +0 -91
- package/src/query/QueryBuilder.test.js +0 -82
- package/src/query/buildSelectSql.test.js +0 -58
- package/tsconfig.json +0 -14
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Traits: reusable model behaviour, as JavaScript mixins. A trait is a
|
|
2
|
+
// function that takes a model class and returns a class extending it:
|
|
3
|
+
//
|
|
4
|
+
// export const SoftLabels = (Base) => class extends Base {
|
|
5
|
+
// static async labelled(label) { return this.where('label', label).get() }
|
|
6
|
+
// }
|
|
7
|
+
//
|
|
8
|
+
// class Post extends use(Model, Searchable({ fields: ['title', 'body'] }), SoftLabels) {
|
|
9
|
+
// static table = 'posts'
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// A trait can bring observer hooks too: `static observers = [...]` on the
|
|
13
|
+
// class it returns runs alongside the model's own observers.
|
|
14
|
+
|
|
15
|
+
export function use(Base, ...traits) {
|
|
16
|
+
return traits.reduce((Current, trait, i) => {
|
|
17
|
+
if (typeof trait !== 'function') {
|
|
18
|
+
throw new TypeError(`use(): trait ${i + 1} is not a function. Did you forget to call a trait factory, e.g. Searchable({ fields })?`)
|
|
19
|
+
}
|
|
20
|
+
const Next = trait(Current)
|
|
21
|
+
if (typeof Next !== 'function' || !(Next === Current || Object.prototype.isPrototypeOf.call(Current, Next))) {
|
|
22
|
+
throw new TypeError(`use(): trait ${i + 1} must return a class that extends the one it was given.`)
|
|
23
|
+
}
|
|
24
|
+
return Next
|
|
25
|
+
}, Base)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
29
|
+
|
|
30
|
+
// Adds Model.search(term) over the given columns:
|
|
31
|
+
// class Post extends use(Model, Searchable({ fields: ['title', 'body'] })) {}
|
|
32
|
+
// await Post.search('tylix') // rows whose title or body contains it
|
|
33
|
+
// await Post.search('tylix', { limit: 10 })
|
|
34
|
+
// Matching is case-insensitive (LIKE on every driver). It uses plain where()
|
|
35
|
+
// queries, one per field, so it works on SQLite, MySQL, PostgreSQL and
|
|
36
|
+
// MongoDB alike; for large tables use a full-text index instead.
|
|
37
|
+
export function Searchable(options = {}) {
|
|
38
|
+
if (typeof options === 'function') {
|
|
39
|
+
throw new TypeError('Searchable is a trait factory: call it with options, e.g. use(Model, Searchable({ fields: ["title"] })).')
|
|
40
|
+
}
|
|
41
|
+
const { fields } = options
|
|
42
|
+
if (!Array.isArray(fields) || fields.length === 0 || !fields.every((f) => IDENTIFIER.test(f))) {
|
|
43
|
+
throw new TypeError('Searchable({ fields }) needs a list of column names, e.g. ["title", "body"].')
|
|
44
|
+
}
|
|
45
|
+
return (Base) =>
|
|
46
|
+
class extends Base {
|
|
47
|
+
static async search(term, { limit = 50 } = {}) {
|
|
48
|
+
const text = String(term ?? '').trim()
|
|
49
|
+
if (text === '') return []
|
|
50
|
+
const pattern = `%${text}%`
|
|
51
|
+
const found = new Map()
|
|
52
|
+
for (const field of fields) {
|
|
53
|
+
for (const row of await this.where(field, 'LIKE', pattern).get()) {
|
|
54
|
+
if (!found.has(row.id)) found.set(row.id, row)
|
|
55
|
+
if (found.size >= limit) return [...found.values()]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...found.values()]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -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
|
-
});
|
package/src/model/Model.test.js
DELETED
|
@@ -1,91 +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", "body"];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let tmpDir;
|
|
16
|
-
let adapter;
|
|
17
|
-
|
|
18
|
-
beforeEach(async () => {
|
|
19
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-model-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 NOT NULL,
|
|
26
|
-
body TEXT,
|
|
27
|
-
created_at TEXT,
|
|
28
|
-
updated_at TEXT
|
|
29
|
-
)
|
|
30
|
-
`);
|
|
31
|
-
ConnectionManager.setAdapter(adapter);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterEach(async () => {
|
|
35
|
-
await adapter.close();
|
|
36
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
37
|
-
ConnectionManager.reset();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("create() inserts a row and returns it", async () => {
|
|
41
|
-
const post = await Post.create({ title: "Hello Tylix", body: "First post" });
|
|
42
|
-
assert.equal(post.title, "Hello Tylix");
|
|
43
|
-
assert.equal(post.body, "First post");
|
|
44
|
-
assert.ok(post.id);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("create() populates created_at and updated_at", async () => {
|
|
48
|
-
const post = await Post.create({ title: "Timestamped", body: "..." });
|
|
49
|
-
assert.ok(post.created_at, "created_at should be set");
|
|
50
|
-
assert.ok(post.updated_at, "updated_at should be set");
|
|
51
|
-
assert.ok(!Number.isNaN(Date.parse(post.created_at)), "created_at should be a valid date string");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("all() returns every row", async () => {
|
|
55
|
-
await Post.create({ title: "One", body: "..." });
|
|
56
|
-
await Post.create({ title: "Two", body: "..." });
|
|
57
|
-
const posts = await Post.all();
|
|
58
|
-
assert.equal(posts.length, 2);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("find() returns a single row by id", async () => {
|
|
62
|
-
const created = await Post.create({ title: "Findable", body: "..." });
|
|
63
|
-
const found = await Post.find(created.id);
|
|
64
|
-
assert.equal(found.title, "Findable");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("update() modifies a row and returns the updated version", async () => {
|
|
68
|
-
const created = await Post.create({ title: "Original", body: "..." });
|
|
69
|
-
const updated = await Post.update(created.id, { title: "Updated" });
|
|
70
|
-
assert.equal(updated.title, "Updated");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("update() bumps updated_at without changing created_at", async () => {
|
|
74
|
-
const created = await Post.create({ title: "Original", body: "..." });
|
|
75
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
76
|
-
const updated = await Post.update(created.id, { title: "Changed" });
|
|
77
|
-
|
|
78
|
-
assert.equal(updated.created_at, created.created_at);
|
|
79
|
-
assert.notEqual(updated.updated_at, created.updated_at);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("delete() removes a row", async () => {
|
|
83
|
-
const created = await Post.create({ title: "ToDelete", body: "..." });
|
|
84
|
-
await Post.delete(created.id);
|
|
85
|
-
const found = await Post.find(created.id);
|
|
86
|
-
assert.equal(found, null);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("create() throws if no fillable fields are provided", async () => {
|
|
90
|
-
await assert.rejects(() => Post.create({ nonExistentField: "x" }));
|
|
91
|
-
});
|
|
@@ -1,82 +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 "../model/ConnectionManager.js";
|
|
8
|
-
import { Model } from "../model/Model.js";
|
|
9
|
-
|
|
10
|
-
class Post extends Model {
|
|
11
|
-
static table = "posts";
|
|
12
|
-
static fillable = ["title", "views", "status"];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let tmpDir;
|
|
16
|
-
let adapter;
|
|
17
|
-
|
|
18
|
-
beforeEach(async () => {
|
|
19
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tylix-querybuilder-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
|
-
views INTEGER,
|
|
27
|
-
status TEXT,
|
|
28
|
-
created_at TEXT,
|
|
29
|
-
updated_at TEXT
|
|
30
|
-
)
|
|
31
|
-
`);
|
|
32
|
-
ConnectionManager.setAdapter(adapter);
|
|
33
|
-
|
|
34
|
-
await Post.create({ title: "Draft Post", views: 10, status: "draft" });
|
|
35
|
-
await Post.create({ title: "Popular Post", views: 500, status: "published" });
|
|
36
|
-
await Post.create({ title: "Medium Post", views: 150, status: "published" });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterEach(async () => {
|
|
40
|
-
await adapter.close();
|
|
41
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
42
|
-
ConnectionManager.reset();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("Post.query() with where() filters correctly", async () => {
|
|
46
|
-
const results = await Post.query().where("status", "published").get();
|
|
47
|
-
assert.equal(results.length, 2);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("where() with explicit operator", async () => {
|
|
51
|
-
const results = await Post.query().where("views", ">", 100).get();
|
|
52
|
-
assert.equal(results.length, 2);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("orderBy() and limit() chain correctly", async () => {
|
|
56
|
-
const results = await Post.query()
|
|
57
|
-
.where("status", "published")
|
|
58
|
-
.orderBy("views", "DESC")
|
|
59
|
-
.limit(1)
|
|
60
|
-
.get();
|
|
61
|
-
|
|
62
|
-
assert.equal(results.length, 1);
|
|
63
|
-
assert.equal(results[0].title, "Popular Post");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("first() returns a single row or null", async () => {
|
|
67
|
-
const found = await Post.query().where("status", "draft").first();
|
|
68
|
-
assert.equal(found.title, "Draft Post");
|
|
69
|
-
|
|
70
|
-
const notFound = await Post.query().where("status", "archived").first();
|
|
71
|
-
assert.equal(notFound, null);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("count() returns the number of matching rows", async () => {
|
|
75
|
-
const count = await Post.query().where("status", "published").count();
|
|
76
|
-
assert.equal(count, 2);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("query() with no where() returns all rows", async () => {
|
|
80
|
-
const results = await Post.query().get();
|
|
81
|
-
assert.equal(results.length, 3);
|
|
82
|
-
});
|