@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.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@tylix/orm",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./src/index.js",
6
+ "scripts": {
7
+ "test": "node --test src/**/*.test.js"
8
+ },
9
+ "dependencies": {
10
+ "@tylix/shared": "*",
11
+ "mongodb": "^6.5.0",
12
+ "mysql2": "^3.9.0",
13
+ "pg": "^8.11.0"
14
+ }
15
+ }
@@ -0,0 +1,24 @@
1
+ import { SqliteAdapter } from "./SqliteAdapter.js";
2
+ import { PostgresAdapter } from "./PostgresAdapter.js";
3
+ import { MysqlAdapter } from "./MysqlAdapter.js";
4
+ import { MongoAdapter } from "./MongoAdapter.js";
5
+
6
+ const ADAPTERS = {
7
+ sqlite: SqliteAdapter,
8
+ postgres: PostgresAdapter,
9
+ mysql: MysqlAdapter,
10
+ mongodb: MongoAdapter,
11
+ };
12
+
13
+ export function createAdapter(databaseConfig) {
14
+ const { driver, ...options } = databaseConfig;
15
+
16
+ const AdapterClass = ADAPTERS[driver];
17
+ if (!AdapterClass) {
18
+ throw new Error(
19
+ `Unknown database driver "${driver}". Available drivers: ${Object.keys(ADAPTERS).join(", ")}`
20
+ );
21
+ }
22
+
23
+ return new AdapterClass(options);
24
+ }
@@ -0,0 +1,35 @@
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
+ });
@@ -0,0 +1,61 @@
1
+ import { buildSelectSql } from '../query/buildSelectSql.js'
2
+
3
+ /**
4
+ * Base contract every Tylix database adapter must implement.
5
+ * Concrete adapters (SqliteAdapter, PostgresAdapter, MysqlAdapter,
6
+ * MongoAdapter...) extend this and implement each method. Model and the
7
+ * migration runner only ever talk to this interface, never to a specific
8
+ * driver.
9
+ */
10
+ export class DatabaseAdapter {
11
+ async connect() {
12
+ throw new Error('connect() not implemented')
13
+ }
14
+
15
+ async close() {
16
+ throw new Error('close() not implemented')
17
+ }
18
+
19
+ async run(sql, params = []) {
20
+ throw new Error('run() not implemented')
21
+ }
22
+
23
+ async get(sql, params = []) {
24
+ throw new Error('get() not implemented')
25
+ }
26
+
27
+ async all(sql, params = []) {
28
+ throw new Error('all() not implemented')
29
+ }
30
+
31
+ columnType(logicalType) {
32
+ throw new Error('columnType() not implemented')
33
+ }
34
+
35
+ async count(table) {
36
+ throw new Error('count() not implemented')
37
+ }
38
+
39
+ async paginate(table, limit, offset) {
40
+ throw new Error('paginate() not implemented')
41
+ }
42
+
43
+ async listTables() {
44
+ throw new Error('listTables() not implemented')
45
+ }
46
+
47
+ /**
48
+ * Runs a structured query descriptor ({ wheres, orders, limitValue,
49
+ * offsetValue }) against `table` and returns matching rows.
50
+ *
51
+ * Default implementation translates the descriptor to "?"-placeholder
52
+ * SQL via buildSelectSql() and delegates to this.all(), which every
53
+ * SQL adapter (Sqlite/Postgres/Mysql) already implements correctly
54
+ * for its own placeholder dialect and type conversions. MongoAdapter
55
+ * overrides this entirely since it has no SQL layer to delegate to.
56
+ */
57
+ async query(table, descriptor = {}) {
58
+ const { sql, params } = buildSelectSql(table, descriptor)
59
+ return this.all(sql, params)
60
+ }
61
+ }
@@ -0,0 +1,274 @@
1
+ import { MongoClient } from 'mongodb'
2
+ import { DatabaseAdapter } from './DatabaseAdapter.js'
3
+
4
+ export function parseQuery(sql) {
5
+ const trimmed = sql.trim().replace(/\s+/g, ' ')
6
+
7
+ let match
8
+
9
+ if ((match = /^CREATE TABLE IF NOT EXISTS (\w+)/i.exec(trimmed))) {
10
+ return { type: 'createTable', table: match[1] }
11
+ }
12
+
13
+ if ((match = /^DROP TABLE IF EXISTS (\w+)/i.exec(trimmed))) {
14
+ return { type: 'dropTable', table: match[1] }
15
+ }
16
+
17
+ if ((match = /^ALTER TABLE (\w+) ADD COLUMN (\w+)/i.exec(trimmed))) {
18
+ return { type: 'alterAddColumn', table: match[1], column: match[2] }
19
+ }
20
+
21
+ if ((match = /^ALTER TABLE (\w+) DROP COLUMN (\w+)$/i.exec(trimmed))) {
22
+ return { type: 'alterDropColumn', table: match[1], column: match[2] }
23
+ }
24
+
25
+ if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
26
+ return { type: 'findOne', table: match[1], field: match[2] }
27
+ }
28
+
29
+ if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
30
+ return { type: 'findOne', table: match[1], field: match[2] }
31
+ }
32
+
33
+ if ((match = /^SELECT \* FROM (\w+)$/i.exec(trimmed))) {
34
+ return { type: 'findAll', table: match[1] }
35
+ }
36
+
37
+ if (
38
+ (match = /^INSERT INTO (\w+) \(([^)]+)\) VALUES \(([^)]+)\)$/i.exec(
39
+ trimmed,
40
+ ))
41
+ ) {
42
+ const columns = match[2].split(',').map((c) => c.trim())
43
+ return { type: 'insert', table: match[1], columns }
44
+ }
45
+
46
+ if ((match = /^UPDATE (\w+) SET (.+) WHERE (\w+) = \?$/i.exec(trimmed))) {
47
+ const setClause = match[2]
48
+ const columns = setClause
49
+ .split(',')
50
+ .map((pair) => pair.trim().split('=')[0].trim())
51
+ return { type: 'update', table: match[1], columns, whereField: match[3] }
52
+ }
53
+
54
+ if ((match = /^DELETE FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
55
+ return { type: 'delete', table: match[1], field: match[2] }
56
+ }
57
+
58
+ throw new Error(
59
+ `MongoAdapter does not recognize this query shape: "${trimmed}"`,
60
+ )
61
+ }
62
+
63
+ export class MongoAdapter extends DatabaseAdapter {
64
+ constructor({ url = 'mongodb://localhost:27017', database } = {}) {
65
+ super()
66
+ this.url = url
67
+ this.dbName = database
68
+ this.client = null
69
+ this.db = null
70
+ this.counters = {}
71
+ }
72
+
73
+ async connect() {
74
+ this.client = new MongoClient(this.url)
75
+ await this.client.connect()
76
+ this.db = this.client.db(this.dbName)
77
+ return this
78
+ }
79
+
80
+ async close() {
81
+ if (this.client) {
82
+ await this.client.close()
83
+ this.client = null
84
+ this.db = null
85
+ }
86
+ }
87
+
88
+ ensureConnected() {
89
+ if (!this.db) {
90
+ throw new Error('MongoAdapter is not connected. Call connect() first.')
91
+ }
92
+ }
93
+
94
+ async nextId(table) {
95
+ const counters = this.db.collection('__tylix_counters')
96
+ const result = await counters.findOneAndUpdate(
97
+ { _id: table },
98
+ { $inc: { seq: 1 } },
99
+ { upsert: true, returnDocument: 'after' },
100
+ )
101
+ return result.seq
102
+ }
103
+
104
+ async run(sql, params = []) {
105
+ this.ensureConnected()
106
+ const query = parseQuery(sql)
107
+
108
+ if (query.type === 'createTable') {
109
+ const collections = await this.db
110
+ .listCollections({ name: query.table })
111
+ .toArray()
112
+ if (collections.length === 0) {
113
+ await this.db.createCollection(query.table)
114
+ }
115
+ return {}
116
+ }
117
+
118
+ if (query.type === 'dropTable') {
119
+ const collections = await this.db
120
+ .listCollections({ name: query.table })
121
+ .toArray()
122
+ if (collections.length > 0) {
123
+ await this.db.collection(query.table).drop()
124
+ }
125
+ return {}
126
+ }
127
+
128
+ if (query.type === 'alterAddColumn') {
129
+ return {}
130
+ }
131
+
132
+ // Unlike ADD, DROP needs a real write: old documents still carry
133
+ // the removed field's data, and it would keep coming back in
134
+ // find()/query() results (Model.all(), etc.) forever unless
135
+ // explicitly stripped from every existing document.
136
+ if (query.type === 'alterDropColumn') {
137
+ await this.db
138
+ .collection(query.table)
139
+ .updateMany({}, { $unset: { [query.column]: '' } })
140
+ return {}
141
+ }
142
+
143
+ if (query.type === 'insert') {
144
+ const id = await this.nextId(query.table)
145
+ const doc = { id }
146
+ query.columns.forEach((col, i) => {
147
+ doc[col] = params[i]
148
+ })
149
+ await this.db.collection(query.table).insertOne(doc)
150
+ return { lastInsertRowid: id }
151
+ }
152
+
153
+ if (query.type === 'update') {
154
+ const whereValue = params[params.length - 1]
155
+ const setValues = params.slice(0, params.length - 1)
156
+ const $set = {}
157
+ query.columns.forEach((col, i) => {
158
+ $set[col] = setValues[i]
159
+ })
160
+ await this.db
161
+ .collection(query.table)
162
+ .updateOne({ [query.whereField]: whereValue }, { $set })
163
+ return {}
164
+ }
165
+
166
+ if (query.type === 'delete') {
167
+ await this.db
168
+ .collection(query.table)
169
+ .deleteOne({ [query.field]: params[0] })
170
+ return {}
171
+ }
172
+
173
+ throw new Error(`run() cannot handle query type "${query.type}"`)
174
+ }
175
+
176
+ async get(sql, params = []) {
177
+ this.ensureConnected()
178
+ const query = parseQuery(sql)
179
+
180
+ if (query.type === 'findOne') {
181
+ const doc = await this.db
182
+ .collection(query.table)
183
+ .findOne({ [query.field]: params[0] }, { projection: { _id: 0 } })
184
+ return doc ?? null
185
+ }
186
+
187
+ throw new Error(`get() cannot handle query type "${query.type}"`)
188
+ }
189
+
190
+ async all(sql, params = []) {
191
+ this.ensureConnected()
192
+ const query = parseQuery(sql)
193
+
194
+ if (query.type === 'findAll') {
195
+ return this.db
196
+ .collection(query.table)
197
+ .find({}, { projection: { _id: 0 } })
198
+ .toArray()
199
+ }
200
+
201
+ if (query.type === 'findOne') {
202
+ return this.db
203
+ .collection(query.table)
204
+ .find({ [query.field]: params[0] }, { projection: { _id: 0 } })
205
+ .toArray()
206
+ }
207
+
208
+ throw new Error(`all() cannot handle query type "${query.type}"`)
209
+ }
210
+
211
+ async count(table) {
212
+ this.ensureConnected()
213
+ return this.db.collection(table).countDocuments()
214
+ }
215
+
216
+ async paginate(table, limit, offset) {
217
+ this.ensureConnected()
218
+ return this.db
219
+ .collection(table)
220
+ .find({}, { projection: { _id: 0 } })
221
+ .sort({ id: 1 })
222
+ .skip(offset)
223
+ .limit(limit)
224
+ .toArray()
225
+ }
226
+
227
+ async listTables() {
228
+ this.ensureConnected()
229
+ const collections = await this.db.listCollections().toArray()
230
+ return collections.map((c) => c.name)
231
+ }
232
+
233
+ columnType() {
234
+ return null
235
+ }
236
+ }
237
+
238
+ MongoAdapter.prototype.query = async function (
239
+ table,
240
+ { wheres = [], orders = [], limitValue, offsetValue } = {},
241
+ ) {
242
+ this.ensureConnected()
243
+
244
+ const filter = {}
245
+ for (const w of wheres) {
246
+ if (w.operator === '=') filter[w.field] = w.value
247
+ else if (w.operator === '!=') filter[w.field] = { $ne: w.value }
248
+ else if (w.operator === '>') filter[w.field] = { $gt: w.value }
249
+ else if (w.operator === '<') filter[w.field] = { $lt: w.value }
250
+ else if (w.operator === '>=') filter[w.field] = { $gte: w.value }
251
+ else if (w.operator === '<=') filter[w.field] = { $lte: w.value }
252
+ else
253
+ throw new Error(
254
+ `Unsupported where operator "${w.operator}" for MongoAdapter`,
255
+ )
256
+ }
257
+
258
+ let cursor = this.db
259
+ .collection(table)
260
+ .find(filter, { projection: { _id: 0 } })
261
+
262
+ if (orders.length > 0) {
263
+ const sort = {}
264
+ orders.forEach((o) => {
265
+ sort[o.field] = o.direction.toUpperCase() === 'DESC' ? -1 : 1
266
+ })
267
+ cursor = cursor.sort(sort)
268
+ }
269
+
270
+ if (offsetValue !== undefined) cursor = cursor.skip(offsetValue)
271
+ if (limitValue !== undefined) cursor = cursor.limit(limitValue)
272
+
273
+ return cursor.toArray()
274
+ }
@@ -0,0 +1,52 @@
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
+ });
@@ -0,0 +1,107 @@
1
+ import mysql from 'mysql2/promise'
2
+ import { DatabaseAdapter } from './DatabaseAdapter.js'
3
+
4
+ const COLUMN_TYPES = {
5
+ increments: 'INT AUTO_INCREMENT PRIMARY KEY',
6
+ string: 'VARCHAR(255)',
7
+ text: 'TEXT',
8
+ boolean: 'TINYINT(1)',
9
+ integer: 'INT',
10
+ date: 'DATE',
11
+ datetime: 'DATETIME',
12
+ json: 'JSON',
13
+ timestamp: 'DATETIME',
14
+ }
15
+
16
+ const ISO_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
17
+
18
+ export function toMysqlDateTime(value) {
19
+ if (typeof value === 'string' && ISO_DATETIME_PATTERN.test(value)) {
20
+ return value.slice(0, 19).replace('T', ' ')
21
+ }
22
+ return value
23
+ }
24
+
25
+ export class MysqlAdapter extends DatabaseAdapter {
26
+ constructor({ host, port, user, password, database } = {}) {
27
+ super()
28
+ this.config = { host, port, user, password, database }
29
+ this.connection = null
30
+ }
31
+
32
+ async connect() {
33
+ this.connection = await mysql.createConnection(this.config)
34
+ return this
35
+ }
36
+
37
+ async close() {
38
+ if (this.connection) {
39
+ await this.connection.end()
40
+ this.connection = null
41
+ }
42
+ }
43
+
44
+ ensureConnected() {
45
+ if (!this.connection) {
46
+ throw new Error('MysqlAdapter is not connected. Call connect() first.')
47
+ }
48
+ }
49
+
50
+ async run(sql, params = []) {
51
+ this.ensureConnected()
52
+ const converted = params.map(toMysqlDateTime)
53
+ const [result] = await this.connection.execute(sql, converted)
54
+ return { ...result, lastInsertRowid: result.insertId }
55
+ }
56
+
57
+ async get(sql, params = []) {
58
+ this.ensureConnected()
59
+ const converted = params.map(toMysqlDateTime)
60
+ const [rows] = await this.connection.execute(sql, converted)
61
+ return rows[0] ?? null
62
+ }
63
+
64
+ async all(sql, params = []) {
65
+ this.ensureConnected()
66
+ const converted = params.map(toMysqlDateTime)
67
+ const [rows] = await this.connection.execute(sql, converted)
68
+ return rows
69
+ }
70
+
71
+ async count(table) {
72
+ this.ensureConnected()
73
+ const [rows] = await this.connection.execute(
74
+ `SELECT COUNT(*) as count FROM ${table}`,
75
+ )
76
+ return Number(rows[0].count)
77
+ }
78
+
79
+ async paginate(table, limit, offset) {
80
+ this.ensureConnected()
81
+ // mysql2 does not support ? placeholders for LIMIT/OFFSET reliably
82
+ // across versions, so these are inlined after being forced to
83
+ // integers here (never from raw user input) to avoid injection.
84
+ const safeLimit = Number.isInteger(limit) ? limit : parseInt(limit, 10)
85
+ const safeOffset = Number.isInteger(offset) ? offset : parseInt(offset, 10)
86
+ const [rows] = await this.connection.execute(
87
+ `SELECT * FROM ${table} ORDER BY id ASC LIMIT ${safeLimit} OFFSET ${safeOffset}`,
88
+ )
89
+ return rows
90
+ }
91
+
92
+ async listTables() {
93
+ this.ensureConnected()
94
+ const [rows] = await this.connection.execute('SHOW TABLES')
95
+ return rows.map((r) => Object.values(r)[0])
96
+ }
97
+
98
+ columnType(logicalType) {
99
+ const sql = COLUMN_TYPES[logicalType]
100
+ if (!sql) {
101
+ throw new Error(
102
+ `MysqlAdapter has no mapping for column type "${logicalType}"`,
103
+ )
104
+ }
105
+ return sql
106
+ }
107
+ }
@@ -0,0 +1,36 @@
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
+ });