@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,91 @@
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
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Fluent query builder returned by Model.query(). Accumulates a
3
+ * structured descriptor and only hits the database when a terminal
4
+ * method (get/first/count) is called.
5
+ */
6
+ export class QueryBuilder {
7
+ constructor(ModelClass) {
8
+ this.ModelClass = ModelClass;
9
+ this.wheres = [];
10
+ this.orders = [];
11
+ this.limitValue = undefined;
12
+ this.offsetValue = undefined;
13
+ }
14
+
15
+ where(field, operatorOrValue, maybeValue) {
16
+ const hasExplicitOperator = maybeValue !== undefined;
17
+ const operator = hasExplicitOperator ? operatorOrValue : "=";
18
+ const value = hasExplicitOperator ? maybeValue : operatorOrValue;
19
+
20
+ this.wheres.push({ field, operator, value });
21
+ return this;
22
+ }
23
+
24
+ orderBy(field, direction = "ASC") {
25
+ const normalized = direction.toUpperCase();
26
+ if (normalized !== "ASC" && normalized !== "DESC") {
27
+ throw new Error(`orderBy direction must be "ASC" or "DESC", got "${direction}"`);
28
+ }
29
+ this.orders.push({ field, direction: normalized });
30
+ return this;
31
+ }
32
+
33
+ limit(value) {
34
+ this.limitValue = value;
35
+ return this;
36
+ }
37
+
38
+ offset(value) {
39
+ this.offsetValue = value;
40
+ return this;
41
+ }
42
+
43
+ toDescriptor() {
44
+ return {
45
+ wheres: this.wheres,
46
+ orders: this.orders,
47
+ limitValue: this.limitValue,
48
+ offsetValue: this.offsetValue,
49
+ };
50
+ }
51
+
52
+ async get() {
53
+ const adapter = this.ModelClass.getAdapter();
54
+ return adapter.query(this.ModelClass.getTable(), this.toDescriptor());
55
+ }
56
+
57
+ async first() {
58
+ const results = await this.limit(1).get();
59
+ return results[0] ?? null;
60
+ }
61
+
62
+ async count() {
63
+ const rows = await this.get();
64
+ return rows.length;
65
+ }
66
+ }
@@ -0,0 +1,82 @@
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
+ });
@@ -0,0 +1,50 @@
1
+ const SUPPORTED_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
2
+
3
+ /**
4
+ * Translates a structured query descriptor into a "?"-placeholder SQL
5
+ * string + params array. Shared by SqliteAdapter, PostgresAdapter, and
6
+ * MysqlAdapter via DatabaseAdapter's default query() implementation --
7
+ * each adapter's existing all()/run() already handles placeholder
8
+ * translation and datetime conversion correctly per driver, so this
9
+ * only needs to speak the common "?" dialect.
10
+ *
11
+ * limit/offset are inlined as validated integers rather than passed as
12
+ * placeholders, since mysql2 does not reliably support placeholders in
13
+ * LIMIT/OFFSET position (same reasoning as Model.paginate()).
14
+ */
15
+ export function buildSelectSql(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
16
+ const params = [];
17
+ let sql = `SELECT * FROM ${table}`;
18
+
19
+ 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
+ });
29
+ sql += ` WHERE ${clauses.join(" AND ")}`;
30
+ }
31
+
32
+ if (orders.length > 0) {
33
+ const clauses = orders.map((o) => `${o.field} ${o.direction}`);
34
+ sql += ` ORDER BY ${clauses.join(", ")}`;
35
+ }
36
+
37
+ if (limitValue !== undefined) {
38
+ const safeLimit = Number.isInteger(limitValue) ? limitValue : parseInt(limitValue, 10);
39
+ if (!Number.isFinite(safeLimit)) throw new Error("limit must be a valid integer");
40
+ sql += ` LIMIT ${safeLimit}`;
41
+ }
42
+
43
+ if (offsetValue !== undefined) {
44
+ const safeOffset = Number.isInteger(offsetValue) ? offsetValue : parseInt(offsetValue, 10);
45
+ if (!Number.isFinite(safeOffset)) throw new Error("offset must be a valid integer");
46
+ sql += ` OFFSET ${safeOffset}`;
47
+ }
48
+
49
+ return { sql, params };
50
+ }
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildSelectSql } from "./buildSelectSql.js";
4
+
5
+ test("builds a plain SELECT with no conditions", () => {
6
+ const { sql, params } = buildSelectSql("posts", {});
7
+ assert.equal(sql, "SELECT * FROM posts");
8
+ assert.deepEqual(params, []);
9
+ });
10
+
11
+ test("builds a WHERE clause with = operator", () => {
12
+ const { sql, params } = buildSelectSql("posts", {
13
+ wheres: [{ field: "status", operator: "=", value: "published" }],
14
+ });
15
+ assert.equal(sql, "SELECT * FROM posts WHERE status = ?");
16
+ assert.deepEqual(params, ["published"]);
17
+ });
18
+
19
+ test("builds multiple WHERE clauses joined by AND", () => {
20
+ const { sql, params } = buildSelectSql("posts", {
21
+ wheres: [
22
+ { field: "status", operator: "=", value: "published" },
23
+ { field: "views", operator: ">", value: 100 },
24
+ ],
25
+ });
26
+ assert.equal(sql, "SELECT * FROM posts WHERE status = ? AND views > ?");
27
+ assert.deepEqual(params, ["published", 100]);
28
+ });
29
+
30
+ test("builds an ORDER BY clause", () => {
31
+ const { sql } = buildSelectSql("posts", {
32
+ orders: [{ field: "created_at", direction: "DESC" }],
33
+ });
34
+ assert.equal(sql, "SELECT * FROM posts ORDER BY created_at DESC");
35
+ });
36
+
37
+ test("inlines LIMIT and OFFSET as validated integers, not placeholders", () => {
38
+ const { sql, params } = buildSelectSql("posts", { limitValue: 10, offsetValue: 20 });
39
+ assert.equal(sql, "SELECT * FROM posts LIMIT 10 OFFSET 20");
40
+ assert.deepEqual(params, []);
41
+ });
42
+
43
+ test("combines where, orderBy, and limit together", () => {
44
+ const { sql, params } = buildSelectSql("posts", {
45
+ wheres: [{ field: "status", operator: "=", value: "published" }],
46
+ orders: [{ field: "created_at", direction: "ASC" }],
47
+ limitValue: 5,
48
+ });
49
+ assert.equal(sql, "SELECT * FROM posts WHERE status = ? ORDER BY created_at ASC LIMIT 5");
50
+ assert.deepEqual(params, ["published"]);
51
+ });
52
+
53
+ test("throws on an unsupported operator", () => {
54
+ assert.throws(
55
+ () => buildSelectSql("posts", { wheres: [{ field: "x", operator: "LIKE", value: "y" }] }),
56
+ /Unsupported where operator/
57
+ );
58
+ });