From c09e87d8cfa58a2e6bca3c79280644f4db2ba9bc Mon Sep 17 00:00:00 2001 From: MURANGWA Date: Sun, 11 Oct 2026 18:54:10 +0200 Subject: [PATCH] Add callback transactions and a public ORM transaction() groups add, set, and del so they write the file once. A throw restores the pages and leaves the file untouched. Collection models are imported from @kin-lang/db/orm. --- README.md | 77 +++++++++++++++-- package.json | 9 +- src/database.ts | 96 ++++++++++++++++++++++ src/index.ts | 2 +- src/orm.ts | 188 ++++++++++++++++++++++++++++++++++++++++++ tests/engine.test.ts | 127 ++++++++++++++++++++++++++++ tests/orm.test.ts | 191 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 683 insertions(+), 7 deletions(-) create mode 100644 src/orm.ts create mode 100644 tests/orm.test.ts diff --git a/README.md b/README.md index 56c7bd4..a8ec0fc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ In-memory relational database with **Kinyarwanda SQL**. Records live in named tables. CRUD matches [`json-base`](https://www.npmjs.com/package/@ndzhwr/json-base): get (with `where` and `limit`), add, set, del, and delAll. The relational operators are selection (`aho`) and inner join (`huza`). -The package is standalone. It reads and writes a `.db` page file, runs a `.kindb` script, and is what Kin calls through `KIN_UBUBIKO`. +The package is standalone. It reads and writes a `.db` page file, runs a `.kindb` script, and is what Kin calls through `KIN_UBUBIKO`. Collection models are imported from `@kin-lang/db/orm`. ```bash npm install @kin-lang/db @@ -102,7 +102,20 @@ db.del({ collection: 'abanyeshuri', where: { id: 2 } }) db.delAll() ``` -`Database.memory()` never touches the disk until `save(path)`. `execute` rolls the in-memory pages back when a statement fails, and it does not write the file in that case. The rollback keeps the previous page buffers; it does not copy the whole file. A statement that changes nothing does not rewrite the file. `add`, `set`, and `del` write the pages that changed. `get` returns copies of the rows. `imipaka` and `get(..., limit)` stop once that many rows have matched. An equality `huza` is hashed, so it does not build the cartesian product. +```ts +const rows = db.transaction((tx) => { + tx.createCollection('abanyeshuri') + tx.add({ collection: 'abanyeshuri', data: { id: 1, izina: 'Aline', amanota: 88 } }) + tx.add({ collection: 'abanyeshuri', data: { id: 2, izina: 'Keza', amanota: 41 } }) + tx.set({ collection: 'abanyeshuri', where: { id: 1 }, data: { amanota: 95 } }) + tx.del({ collection: 'abanyeshuri', where: { id: 2 } }) + return tx.get({ collection: 'abanyeshuri' }) +}) +``` + +`Database.memory()` never touches the disk until `save(path)`. `execute` rolls the in-memory pages back when a statement fails, and it does not write the file in that case. The rollback keeps the previous page buffers; it does not copy the whole file. A statement that changes nothing does not rewrite the file. Outside a transaction, `add`, `set`, and `del` write the pages that changed. `get` returns copies of the rows. `imipaka` and `get(..., limit)` stop once that many rows have matched. An equality `huza` is hashed, so it does not build the cartesian product. + +`transaction(callback)` is the explicit way to group those writes. The callback receives a transaction handle with the same methods. `add`, `set`, `del`, `execute`, and the other mutating methods — on that handle or on the database — change memory only while the callback runs. They do not each write the file. A model from `@kin-lang/db/orm` joins that transaction through `kora`, `shira`, `hindura`, and `siba`. The file is written once when the outermost callback returns. A throw restores the pages from the start of that callback and leaves the file untouched. The callback must finish synchronously; a returned promise is rejected and the changes are rolled back. `save()` inside a callback throws `E_TRANSACTION`. A nested `transaction()` is a savepoint: catching its error keeps the outer changes, and only the outermost success writes the file. `where` accepts the json-base operators `lt`, `lte`, `gt`, `gte`, and `eq`, plus `munsi`, `munsi_cyangwa`, `hejuru`, `hejuru_cyangwa`, and `ni`. `get`, `set`, and `del` use the same planner as `hitamo`, so an equality on an indexed column does not walk the table. @@ -111,6 +124,42 @@ const script = new URL('./examples/ishuri.kindb', import.meta.url) Database.runFile(script, { db: './ishuri.db' }) ``` +## ORM + +`import { model } from '@kin-lang/db/orm'` describes one collection. `on` binds that description to a database or a transaction. `kora`, `shira`, `hitamo`, `hindura`, and `siba` call that session. Inside `transaction()`, those calls change memory only, and the file is written once when the outermost callback returns. + +Field types are `number`, `string`, `boolean`, and `any`. The Kinyarwanda names `umubare`, `ijambo`, `ukuri`, and `ikintu` name the same types. `null` is allowed in every field. `shira` and `hindura` accept the listed fields, and a value has to match its field. `where` uses the same comparisons as `Database.get`. Each key has to be a field on the model. + +```ts +import { Database } from '@kin-lang/db' +import { model } from '@kin-lang/db/orm' + +const Abanyeshuri = model('abanyeshuri', { + id: 'number', + izina: 'string', + amanota: 'number', + yize: 'boolean', +}) + +const db = Database.open('./ishuri.db', { create: true }) +const abanyeshuri = Abanyeshuri.on(db) +abanyeshuri.kora() +abanyeshuri.shira({ id: 1, izina: 'Aline', amanota: 88, yize: true }) +abanyeshuri.shira({ id: 2, izina: 'Keza', amanota: 41, yize: false }) +abanyeshuri.hitamo({ where: { amanota: { gte: 50 } }, limit: 10 }) +abanyeshuri.hindura({ where: { id: 1 }, data: { amanota: 90 } }) +abanyeshuri.siba({ where: { id: 2 } }) + +const rows = db.transaction((tx) => { + const scoped = Abanyeshuri.on(tx) + scoped.shira({ id: 3, izina: 'Kaliza', amanota: 70, yize: true }) + scoped.hindura({ where: { id: 1 }, data: { amanota: 95 } }) + return scoped.hitamo() +}) +``` + +`kora` fails when the collection already exists. The same `model(...)` value can be bound to more than one database. + ## CLI ```bash @@ -144,11 +193,28 @@ From a `.kin` file, after this package is installed next to Kin: reka db = KIN_UBUBIKO.fungura("ishuri.db") db.baza("KORA ITSINDA abanyeshuri") db.shira("abanyeshuri", { id: 1, izina: "Aline", amanota: 88 }) +db.shira("abanyeshuri", { id: 2, izina: "Keza", amanota: 41 }) reka abatsinze = db.hitamo("abanyeshuri", { amanota: { hejuru_cyangwa: 50 } }) tangaza_amakuru(abatsinze) -db.bika() + +porogaramu_ntoya andika(tx) { + tx.shira("abanyeshuri", { id: 3, izina: "Kaliza", amanota: 70 }) + tx.hindura("abanyeshuri", { id: 1 }, { amanota: 95 }) + tx.siba("abanyeshuri", { id: 2 }) +} +db.igikorwa(andika) + +reka Abanyeshuri = db.icyitegererezo("abanyeshuri", { + id: "umubare", + izina: "ijambo", + amanota: "umubare" +}) +Abanyeshuri.shira({ id: 4, izina: "Kaliza", amanota: 70 }) +reka batsinze = Abanyeshuri.hitamo({ amanota: { hejuru_cyangwa: 50 } }) ``` +`igikorwa` is `transaction`. `shira`, `hindura`, and `siba` inside it do not write on their own. The file is written once when the function returns. `bika` inside `igikorwa` is an error. `icyitegererezo` is the model for one collection, imported in JavaScript from `@kin-lang/db/orm`. `kora`, `shira`, `hindura`, and `siba` on that model join an open `igikorwa`. The collection already exists from `baza`, so this model does not call `kora`. + `KIN_UBUBIKO.mu_mutwe()` is a memory database. Paths are relative to the `.kin` file, same as `KIN_INYANDIKO`. See `examples/ububiko` in the Kin repository. ## Errors @@ -166,8 +232,9 @@ Failures throw `KinDbError` with a `code`: | `E_NOT_FOUND` | `hindura` / `siba` matched nothing. | | `E_DUPLICATE` | `hindura` matched more than one row. | | `E_OPERATOR` | The comparison is not one of lt/gt/lte/gte/eq. | -| `E_TYPE` | A value, limit, or name has the wrong shape. | +| `E_TYPE` | A value, limit, name, or model field has the wrong shape. | | `E_IO` | The `.db` or `.kindb` file cannot be read or written. | +| `E_TRANSACTION` | `save()` ran inside a transaction, or the callback returned a promise. | ## Development @@ -177,4 +244,4 @@ npm test npm run build ``` -The published entry is `dist/index.js`. The `kindb` binary is `dist/cli.js`. +The published entries are `dist/index.js` and `dist/orm.js` (`@kin-lang/db/orm`). The `kindb` binary is `dist/cli.js`. diff --git a/package.json b/package.json index 1219cfa..9379496 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,12 @@ "require": "./dist/index.js", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./orm": { + "types": "./dist/orm.d.ts", + "require": "./dist/orm.js", + "import": "./dist/orm.js", + "default": "./dist/orm.js" } }, "engines": { @@ -34,7 +40,8 @@ "database", "json", "sql", - "kindb" + "kindb", + "orm" ], "author": "Kin contributors", "license": "MIT", diff --git a/src/database.ts b/src/database.ts index c70af1e..c1b1627 100644 --- a/src/database.ts +++ b/src/database.ts @@ -19,6 +19,30 @@ export interface RunFileOptions { create?: boolean; } +/** + * The database handle passed to {@link Database.transaction}. + * Its methods are the same mutations as {@link Database}, but they do not + * write the file. The open transaction writes once, when its callback returns. + */ +export interface Transaction { + execute(source: string): ExecuteResult; + /** Run a script whose last statement must be HITAMO. Returns those rows. */ + query(source: string): Row[]; + collections(): string[]; + createCollection(name: string, columns?: ColumnDef[]): void; + dropCollection(name: string): void; + add(params: { collection: string; data: Row }): Row; + get(params: { collection: string; where?: Where; limit?: number }): Row[]; + set(params: { collection: string; where: Where; data: Row }): void; + del(params: { collection: string; where: Where }): number; + delAll(): void; + /** + * A nested transaction is a savepoint. It does not write the file. + * A throw restores only the changes made inside this callback. + */ + transaction(body: (tx: Transaction) => T): T; +} + /** * In-memory relational database that can be tied to a `.db` page file. * Kinyarwanda SQL is executed with {@link Database.execute}. The same data @@ -27,6 +51,8 @@ export interface RunFileOptions { export class Database { private store: Store; private filePath: string | null; + /** Open transaction callbacks. Mutations skip the file until the outermost one returns. */ + private transactionDepth = 0; private constructor(store: Store, filePath: string | null) { this.store = store; @@ -69,9 +95,48 @@ export class Database { return this.filePath; } + /** + * Run `body` as one transaction. + * + * `add`, `set`, `del`, and the other mutating methods change memory only + * while `body` is running, whether they are called on `tx` or on this + * database. They do not each write the file. The file is written once + * when the outermost callback returns. A throw restores the pages from + * the start of this callback and does not write. + * + * The callback must finish synchronously. A nested {@link transaction} + * is a savepoint: catching its error keeps the outer changes. + */ + transaction(body: (tx: Transaction) => T): T { + if (typeof body !== 'function') { + throw new KinDbError('E_TYPE', 'transaction() expects a function'); + } + const snapshot = this.store.clone(); + this.transactionDepth += 1; + let released = false; + try { + const value = body(transactionView(this)); + if (isThenable(value)) { + throw new KinDbError( + 'E_TRANSACTION', + 'transaction() callback must finish synchronously', + ); + } + this.transactionDepth -= 1; + released = true; + if (this.transactionDepth === 0) this.commit(); + return value; + } catch (error) { + this.store = snapshot; + if (!released) this.transactionDepth -= 1; + throw error; + } + } + /** * Run Kinyarwanda SQL. A failure rolls the in-memory data back to the * state before this call, and does not write the `.db` file. + * Inside {@link transaction}, the write waits for that callback to return. */ execute(source: string): ExecuteResult { const snapshot = this.store.clone(); @@ -96,6 +161,12 @@ export class Database { /** Write the `.db` file. Passing a path binds future saves to that file. */ save(filePath?: string): void { + if (this.transactionDepth > 0) { + throw new KinDbError( + 'E_TRANSACTION', + 'save() cannot run inside a transaction. The transaction writes the file when the callback returns', + ); + } if (filePath) { const next = resolve(filePath); const full = next !== this.filePath; @@ -150,11 +221,36 @@ export class Database { } private commit(): void { + if (this.transactionDepth > 0) return; if (!this.filePath) return; this.store.writeTo(this.filePath, false); } } +function transactionView(db: Database): Transaction { + return { + execute: (source) => db.execute(source), + query: (source) => db.query(source), + collections: () => db.collections(), + createCollection: (name, columns) => db.createCollection(name, columns), + dropCollection: (name) => db.dropCollection(name), + add: (params) => db.add(params), + get: (params) => db.get(params), + set: (params) => db.set(params), + del: (params) => db.del(params), + delAll: () => db.delAll(), + transaction: (body) => db.transaction(body), + }; +} + +function isThenable(value: unknown): boolean { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + function readStore(filePath: string): Store { let bytes: Buffer; try { diff --git a/src/index.ts b/src/index.ts index 0d0065b..76ad3a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { KinDbError, isKinDbError } from './errors'; export { Database } from './database'; -export type { OpenOptions, RunFileOptions } from './database'; +export type { OpenOptions, RunFileOptions, Transaction } from './database'; export { executeSource, executeStatements } from './execute'; export type { ExecuteResult } from './execute'; export { parse } from './parser/parser'; diff --git a/src/orm.ts b/src/orm.ts new file mode 100644 index 0000000..2fbf0d8 --- /dev/null +++ b/src/orm.ts @@ -0,0 +1,188 @@ +import type { Transaction } from './database'; +import { KinDbError } from './errors'; +import { assertValueType, type ColumnDef } from './pager/record'; +import { + assertCollectionName, + assertRow, + type FieldFilter, + type JsonValue, + type Row, + type Where, +} from './value'; + +/** + * Column types accepted by {@link model}. + * `umubare`, `ijambo`, `ukuri`, and `ikintu` are the Kinyarwanda names + * used by `kora itsinda` and by Kin's `icyitegererezo`. + */ +const FIELD_TYPE = { + number: 'number', + umubare: 'number', + string: 'string', + ijambo: 'string', + boolean: 'boolean', + ukuri: 'boolean', + any: 'any', + ikintu: 'any', +} as const; + +export type FieldKind = keyof typeof FIELD_TYPE; + +/** A collection's fields: each name maps to a {@link FieldKind}. */ +export type ModelFields = Record; + +type StoredValue = { + number: number; + string: string; + boolean: boolean; + any: JsonValue; +}; + +/** One field's stored value. `null` (`ubusa`) is allowed on every field. */ +export type FieldValue = StoredValue[(typeof FIELD_TYPE)[K]]; + +/** Columns written by {@link Repository.shira} and {@link Repository.hindura}. Every field is optional. */ +export type ModelRow = { + [K in keyof F]?: FieldValue | null; +}; + +/** `where` for a model. Keys are the model's fields. Comparisons match `Database.get`. */ +export type ModelWhere = { + [K in keyof F]?: FieldFilter; +}; + +export interface Model { + readonly collection: string; + /** Declared columns, with Kinyarwanda type names normalized to number, string, boolean, or any. */ + readonly columns: ColumnDef[]; + /** Bind this description to a database or to an open transaction. */ + on(session: Transaction): Repository; +} + +/** + * One collection on one session. + * `kora`, `shira`, `hindura`, and `siba` call that session, so an open + * transaction still writes the file once when its callback returns. + */ +export interface Repository { + readonly collection: string; + readonly columns: ColumnDef[]; + /** Create the collection with this model's columns. */ + kora(): void; + shira(data: ModelRow): ModelRow; + hitamo(params?: { where?: ModelWhere; limit?: number }): ModelRow[]; + hindura(params: { where: ModelWhere; data: ModelRow }): void; + siba(params: { where: ModelWhere }): number; +} + +const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Describe one collection. + * Call {@link Model.on} with a `Database` or a transaction, then + * `kora`, `shira`, `hitamo`, `hindura`, and `siba` run on that session. + */ +export function model(collection: string, fields: F): Model { + assertCollectionName(collection); + const columns = normalizeColumns(collection, fields); + return { + collection, + columns, + on(session) { + return bind(session, collection, columns); + }, + }; +} + +function normalizeColumns(collection: string, fields: ModelFields): ColumnDef[] { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) { + throw new KinDbError('E_TYPE', 'model() expects an object of field types'); + } + const columns: ColumnDef[] = []; + for (const name of Object.keys(fields)) { + assertFieldName(collection, name); + const raw: unknown = fields[name]; + if (typeof raw !== 'string' || !isFieldKind(raw)) { + throw new KinDbError( + 'E_TYPE', + `Field '${name}' on model '${collection}' has type '${String(raw)}', expected number, string, boolean, any, umubare, ijambo, ukuri, or ikintu`, + ); + } + columns.push({ name, type: FIELD_TYPE[raw] }); + } + return columns; +} + +function isFieldKind(value: string): value is FieldKind { + return Object.prototype.hasOwnProperty.call(FIELD_TYPE, value); +} + +function assertFieldName(collection: string, name: string): void { + if (!NAME_PATTERN.test(name)) { + throw new KinDbError( + 'E_TYPE', + `Field '${name}' on model '${collection}' must be a letter or underscore followed by letters, digits, or underscores`, + ); + } +} + +function bind( + session: Transaction, + collection: string, + columns: ColumnDef[], +): Repository { + const byName = new Map(columns.map((column) => [column.name, column])); + + function assertData(data: ModelRow): Row { + assertRow(data, 'data'); + const row = data as Row; + for (const key of Object.keys(row)) { + const column = byName.get(key); + if (!column) { + throw new KinDbError('E_TYPE', `Field '${key}' is not on model '${collection}'`); + } + assertValueType(column, row[key]); + } + return row; + } + + function assertWhere(where: ModelWhere): Where { + if (where === null || typeof where !== 'object' || Array.isArray(where)) { + throw new KinDbError('E_TYPE', 'where must be an object'); + } + for (const key of Object.keys(where)) { + if (!byName.has(key)) { + throw new KinDbError('E_TYPE', `Field '${key}' is not on model '${collection}'`); + } + } + return where as Where; + } + + return { + collection, + columns, + kora() { + session.createCollection(collection, columns.map((column) => ({ ...column }))); + }, + shira(data) { + return session.add({ collection, data: assertData(data) }) as ModelRow; + }, + hitamo(params = {}) { + return session.get({ + collection, + where: params.where === undefined ? undefined : assertWhere(params.where), + limit: params.limit, + }) as ModelRow[]; + }, + hindura(params) { + session.set({ + collection, + where: assertWhere(params.where), + data: assertData(params.data), + }); + }, + siba(params) { + return session.del({ collection, where: assertWhere(params.where) }); + }, + }; +} diff --git a/tests/engine.test.ts b/tests/engine.test.ts index c02ebab..0e52d86 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -551,6 +551,133 @@ describe('table output', () => { }); }); +describe('transactions', () => { + test('add, set, and del write the file once when the callback returns', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.createCollection('abanyeshuri'); + db.add({ collection: 'abanyeshuri', data: { id: 2, izina: 'Keza', amanota: 40 } }); + const before = readFileSync(file); + + const rows = db.transaction((tx) => { + tx.add({ collection: 'abanyeshuri', data: { id: 1, izina: 'Aline', amanota: 88 } }); + db.set({ collection: 'abanyeshuri', where: { id: 1 }, data: { amanota: 90 } }); + tx.del({ collection: 'abanyeshuri', where: { id: 2 } }); + expect(readFileSync(file)).toEqual(before); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toEqual([ + { id: 2, izina: 'Keza', amanota: 40 }, + ]); + return tx.get({ collection: 'abanyeshuri' }); + }); + + expect(rows).toEqual([{ id: 1, izina: 'Aline', amanota: 90 }]); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toEqual([ + { id: 1, izina: 'Aline', amanota: 90 }, + ]); + expect(db.get({ collection: 'abanyeshuri' })).toEqual([ + { id: 1, izina: 'Aline', amanota: 90 }, + ]); + }); + + test('a failed callback restores memory and leaves the file untouched', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.execute('kora itsinda t shira muri t { id: 1, izina: "Aline" }'); + const before = readFileSync(file); + + expect(() => + db.transaction((tx) => { + tx.add({ collection: 't', data: { id: 2, izina: 'Keza' } }); + db.del({ collection: 't', where: { id: 9 } }); + }), + ).toThrow(KinDbError); + + expect(db.get({ collection: 't' })).toEqual([{ id: 1, izina: 'Aline' }]); + expect(readFileSync(file)).toEqual(before); + expect(Database.open(file).get({ collection: 't' })).toEqual([{ id: 1, izina: 'Aline' }]); + }); + + test('execute inside a transaction waits to write, and a caught failure keeps earlier rows', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.createCollection('t'); + const before = readFileSync(file); + + db.transaction((tx) => { + tx.add({ collection: 't', data: { id: 1 } }); + expect(() => tx.execute('shira muri t { id: 2 } siba muri missing aho id = 1')).toThrow( + /was not found/, + ); + expect(tx.get({ collection: 't' })).toEqual([{ id: 1 }]); + expect(readFileSync(file)).toEqual(before); + }); + + expect(Database.open(file).get({ collection: 't' })).toEqual([{ id: 1 }]); + }); + + test('a nested transaction rolls back only its own changes when the error is caught', () => { + const db = Database.memory(); + db.createCollection('t'); + const kept = db.transaction((tx) => { + tx.add({ collection: 't', data: { id: 1 } }); + expect(() => + tx.transaction((inner) => { + inner.add({ collection: 't', data: { id: 2 } }); + throw new Error('stop'); + }), + ).toThrow('stop'); + return tx.get({ collection: 't' }); + }); + expect(kept).toEqual([{ id: 1 }]); + expect(db.get({ collection: 't' })).toEqual([{ id: 1 }]); + }); + + test('save inside a transaction is rejected and writes nothing', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.createCollection('t'); + const before = readFileSync(file); + let code = ''; + try { + db.transaction((tx) => { + tx.add({ collection: 't', data: { id: 1 } }); + db.save(); + }); + } catch (error) { + expect(error).toBeInstanceOf(KinDbError); + code = (error as KinDbError).code; + } + expect(code).toBe('E_TRANSACTION'); + expect(db.get({ collection: 't' })).toEqual([]); + expect(readFileSync(file)).toEqual(before); + }); + + test('a returned promise is rejected and the changes are rolled back', () => { + const db = Database.memory(); + db.createCollection('t'); + let code = ''; + try { + db.transaction(async (tx) => { + tx.add({ collection: 't', data: { id: 1 } }); + }); + } catch (error) { + expect(error).toBeInstanceOf(KinDbError); + code = (error as KinDbError).code; + } + expect(code).toBe('E_TRANSACTION'); + expect(db.get({ collection: 't' })).toEqual([]); + }); + + test('transaction() rejects a value that is not a function', () => { + const db = Database.memory(); + expect(() => db.transaction(undefined as unknown as () => void)).toThrow(/expects a function/); + }); +}); + function range(start: number, end: number): number[] { const values: number[] = []; for (let value = start; value <= end; value += 1) values.push(value); diff --git a/tests/orm.test.ts b/tests/orm.test.ts new file mode 100644 index 0000000..4c3439f --- /dev/null +++ b/tests/orm.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; +import { Database } from '../src/database'; +import { KinDbError } from '../src/errors'; +import { model } from '../src/orm'; + +const dirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'kindb-orm-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const Students = model('abanyeshuri', { + id: 'number', + izina: 'string', + amanota: 'number', + yize: 'boolean', + note: 'any', +}); + +describe('orm', () => { + test('creates a typed collection and reads it back with where and limit', () => { + const db = Database.memory(); + const students = Students.on(db); + students.kora(); + expect(students.shira({ id: 1, izina: 'Aline', amanota: 88, yize: true, note: null })).toEqual({ + id: 1, + izina: 'Aline', + amanota: 88, + yize: true, + note: null, + }); + students.shira({ id: 2, izina: 'Keza', amanota: 40, yize: false, note: { text: 'see' } }); + students.hindura({ where: { id: 1 }, data: { amanota: 90 } }); + expect(students.hitamo({ where: { amanota: { gte: 50 } }, limit: 10 })).toEqual([ + { id: 1, izina: 'Aline', amanota: 90, yize: true, note: null }, + ]); + expect(students.hitamo({ where: { id: '1' } })).toEqual([ + { id: 1, izina: 'Aline', amanota: 90, yize: true, note: null }, + ]); + expect(students.siba({ where: { id: 2 } })).toBe(1); + expect(students.hitamo()).toEqual([ + { id: 1, izina: 'Aline', amanota: 90, yize: true, note: null }, + ]); + expect(() => students.kora()).toThrow(/already exists/); + }); + + test('normalizes Kinyarwanda and English field names to column types', () => { + const defined = model('abanyeshuri', { + a: 'number', + b: 'umubare', + c: 'string', + d: 'ijambo', + e: 'boolean', + f: 'ukuri', + g: 'any', + h: 'ikintu', + }); + expect(defined.collection).toBe('abanyeshuri'); + expect(defined.columns).toEqual([ + { name: 'a', type: 'number' }, + { name: 'b', type: 'number' }, + { name: 'c', type: 'string' }, + { name: 'd', type: 'string' }, + { name: 'e', type: 'boolean' }, + { name: 'f', type: 'boolean' }, + { name: 'g', type: 'any' }, + { name: 'h', type: 'any' }, + ]); + }); + + test('rejects a bad description, an unknown field, and a value of the wrong type', () => { + expect(() => model('abanyeshuri', { id: 'itaribyo' as 'number' })).toThrow(/itaribyo/); + expect(() => model('1bad', { id: 'number' })).toThrow(/Collection name/); + expect(() => model('t', { '1id': 'number' })).toThrow(/Field '1id'/); + expect(() => model('t', { id: 1 as unknown as 'number' })).toThrow(/has type '1'/); + expect(() => model('t', ['number'] as unknown as { id: 'number' })).toThrow( + /object of field types/, + ); + + const db = Database.memory(); + const students = model('abanyeshuri', { id: 'number', izina: 'string', yize: 'ukuri' }).on(db); + students.kora(); + expect(() => students.shira({ id: 1, extra: true } as { id: number })).toThrow(/not on model/); + expect(() => students.shira({ id: '1' } as { id: number })).toThrow(/expects a number/); + expect(() => students.shira({ id: 1, yize: 'yes' } as { id: number })).toThrow(/nibyo or sibyo/); + students.shira({ id: 1, izina: 'Aline' }); + expect(() => students.hindura({ where: { id: 1 }, data: { id: 'x' } as { id: number } })).toThrow( + /expects a number/, + ); + expect(students.hitamo()).toEqual([{ id: 1, izina: 'Aline' }]); + expect(db.get({ collection: 'abanyeshuri' })).toEqual([{ id: 1, izina: 'Aline' }]); + }); + + test('where must name a field on the model', () => { + const db = Database.memory(); + const students = model('abanyeshuri', { id: 'number' }).on(db); + students.kora(); + students.shira({ id: 1 }); + expect(() => students.hitamo({ where: { izina: 'Aline' } as { id: number } })).toThrow( + /not on model/, + ); + expect(() => students.siba({ where: { izina: 'Aline' } as { id: number } })).toThrow( + /not on model/, + ); + let code = ''; + try { + students.siba({ where: { id: 9 } }); + } catch (error) { + expect(error).toBeInstanceOf(KinDbError); + code = (error as KinDbError).code; + } + expect(code).toBe('E_NOT_FOUND'); + expect(students.hitamo()).toEqual([{ id: 1 }]); + }); + + test('one model definition binds to two databases', () => { + const first = Database.memory(); + const second = Database.memory(); + Students.on(first).kora(); + Students.on(second).kora(); + Students.on(first).shira({ id: 1, izina: 'Aline' }); + expect(Students.on(second).hitamo()).toEqual([]); + expect(Students.on(first).hitamo({ where: { id: 1 } })).toEqual([ + { id: 1, izina: 'Aline' }, + ]); + }); + + test('an empty field list stores an empty row', () => { + const db = Database.memory(); + const logs = model('logs', {}).on(db); + logs.kora(); + expect(logs.shira({})).toEqual({}); + expect(logs.hitamo()).toEqual([{}]); + }); + + test('a repository opened before the transaction writes the file once', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + const students = Students.on(db); + students.kora(); + const before = readFileSync(file); + + db.transaction(() => { + students.shira({ id: 1, izina: 'Aline', amanota: 88 }); + students.hindura({ where: { id: 1 }, data: { amanota: 90 } }); + expect(readFileSync(file)).toEqual(before); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toEqual([]); + }); + + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toEqual([ + { id: 1, izina: 'Aline', amanota: 90 }, + ]); + }); + + test('a repository on the transaction writes once, and a failure rolls back', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + const before = readFileSync(file); + + db.transaction((tx) => { + const students = Students.on(tx); + students.kora(); + students.shira({ id: 1, izina: 'Aline', amanota: 88 }); + students.siba({ where: { id: 1 } }); + expect(readFileSync(file)).toEqual(before); + }); + expect(Database.open(file).collections()).toEqual(['abanyeshuri']); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toEqual([]); + + const afterCreate = readFileSync(file); + expect(() => + db.transaction((tx) => { + Students.on(tx).shira({ id: 2, izina: 'Keza', amanota: 40 }); + Students.on(tx).siba({ where: { id: 9 } }); + }), + ).toThrow(KinDbError); + expect(readFileSync(file)).toEqual(afterCreate); + expect(db.get({ collection: 'abanyeshuri' })).toEqual([]); + }); +});