├── .gitattributes ├── .editorconfig ├── .gitignore ├── test ├── tsconfig.json ├── Test.spec.ts ├── ColumnTypes.spec.ts ├── User.spec.ts └── JoiValidation.spec.ts ├── .windsurfignore ├── .vscode └── settings.json ├── tsconfig.json ├── LICENSE ├── package.json ├── scripts └── checkpoint.cjs ├── lib ├── index.d.ts ├── index.js.map └── index.js ├── MIGRATION_GUIDE_AVA6_NODE22.md ├── README.md └── src └── index.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS # 2 | ################### 3 | .DS_Store 4 | .idea 5 | Thumbs.db 6 | tmp/ 7 | temp/ 8 | 9 | 10 | # Node.js # 11 | ################### 12 | node_modules 13 | 14 | 15 | # NYC # 16 | ################### 17 | coverage 18 | *.lcov 19 | .nyc_output 20 | 21 | 22 | # Files # 23 | ################### 24 | *.log 25 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "target": "ES2021", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "isolatedModules": false, 10 | "skipLibCheck": true, 11 | "sourceMap": true 12 | // esModuleInterop and allowSyntheticDefaultImports are inherited from ../tsconfig.json 13 | }, 14 | "include": [ 15 | "./**/*.ts" 16 | ], 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test/Test.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { Model, Schema, type Column } from '../lib'; 3 | 4 | // Define a minimal schema for testing 5 | const columns: Column[] = [ 6 | { name: 'id', type: 'string', constraints: [{ type: 'PRIMARY KEY' }] } 7 | ]; 8 | 9 | const testSchema = new Schema({ 10 | table_name: 'test_table', 11 | columns, 12 | timestamps: false, 13 | softDeletes: false 14 | }); 15 | 16 | test.beforeEach((t: any) => { 17 | const model = new Model(testSchema); 18 | Object.assign(t.context, { model }); 19 | }); 20 | 21 | test('returns itself', (t: any) => { 22 | t.true(t.context.model instanceof Model); 23 | }); -------------------------------------------------------------------------------- /.windsurfignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | yarn.lock 5 | pnpm-lock.yaml 6 | 7 | # Tests 8 | 9 | 10 | # Build output 11 | dist/ 12 | lib/ 13 | build/ 14 | *.tsbuildinfo 15 | 16 | # Environment files 17 | .env 18 | .env.* 19 | !.env.example 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Testing 29 | coverage/ 30 | .nyc_output/ 31 | 32 | # Editor directories and files 33 | .idea 34 | .vscode/ 35 | *.suo 36 | *.ntvs* 37 | *.njsproj 38 | *.sln 39 | *.sw? 40 | 41 | # OS generated files 42 | .DS_Store 43 | .DS_Store? 44 | ._* 45 | .Spotlight-V100 46 | .Trashes 47 | ehthumbs.db 48 | Thumbs.db 49 | 50 | # Local development 51 | .wrangler/ 52 | .rts2_cache_cjs/ 53 | .rts2_cache_esm/ 54 | 55 | # Miniflare SQLite databases 56 | *.sqlite 57 | *.sqlite-journal 58 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#fa1b49", 4 | "activityBar.background": "#fa1b49", 5 | "activityBar.foreground": "#e7e7e7", 6 | "activityBar.inactiveForeground": "#e7e7e799", 7 | "activityBarBadge.background": "#155e02", 8 | "activityBarBadge.foreground": "#e7e7e7", 9 | "commandCenter.border": "#e7e7e799", 10 | "sash.hoverBorder": "#fa1b49", 11 | "statusBar.background": "#dd0531", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#fa1b49", 14 | "statusBarItem.remoteBackground": "#dd0531", 15 | "statusBarItem.remoteForeground": "#e7e7e7", 16 | "titleBar.activeBackground": "#dd0531", 17 | "titleBar.activeForeground": "#e7e7e7", 18 | "titleBar.inactiveBackground": "#dd053199", 19 | "titleBar.inactiveForeground": "#e7e7e799" 20 | }, 21 | "peacock.color": "#dd0531" 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "ES2021","esnext.asynciterable"], 4 | "types": ["node"], 5 | "target": "ES2021", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "./lib", 9 | "noImplicitAny": false, 10 | "declaration": true, 11 | "strict": true, /* enable strict type checking option */ 12 | "emitDecoratorMetadata": true, /* Send design-type metadata for decorator declaration to source */ 13 | "experimentalDecorators": true, /* enabled for ES decorators */ 14 | "sourceMap": true, /* Use Source Map */ 15 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules without default export */ 16 | "esModuleInterop": true, /*'require' and'import' compatible */ 17 | "skipLibCheck": true, /* Omit type checking of all declaration files (*.d.ts) */ 18 | "resolveJsonModule": true, /* Include modules imported with .json extension */ 19 | "baseUrl": "src", 20 | }, 21 | "include": [ 22 | "src/**/*" ], 23 | "exclude": [ 24 | "node_modules", 25 | "**/*.spec.ts", 26 | "**/*.test.ts", 27 | ] 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Hacksur (http://hacksur.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "model-one", 3 | "description": "Model for D1", 4 | "version": "0.3.1", 5 | "main": "./lib/index.js", 6 | "typings": "./lib/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/hacksur/model-one" 10 | }, 11 | "author": "Julian Clatro (https://hacksur.com)", 12 | "contributors": [ 13 | "Julian Clatro (https://hacksur.com)" 14 | ], 15 | "license": "MIT", 16 | "scripts": { 17 | "checkpoint": "node ./scripts/checkpoint.cjs", 18 | "watch-build": "tsc -w", 19 | "watch-test": "ava --watch", 20 | "dev": "concurrently 'npm:watch-*'", 21 | "test": " NODE_NO_WARNINGS=1 npx ava", 22 | "build": "tsc" 23 | }, 24 | "keywords": [ 25 | "d1", 26 | "model", 27 | "form" 28 | ], 29 | "devDependencies": { 30 | "ava": "6.3.0", 31 | "browser-env": "^3.3.0", 32 | "concurrently": "^7.3.0", 33 | "joi": "latest", 34 | "miniflare": "4.20250508.0", 35 | "better-sqlite3": "11.10.0", 36 | "ts-node": "^10.9.2", 37 | "ts-node-dev": "^2.0.0", 38 | "typescript": "^5.4.5" 39 | }, 40 | "engines": { 41 | "node": ">= 10" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/hacksur/model-one/issues", 45 | "email": "jclatro@yahoo.com" 46 | }, 47 | "homepage": "https://github.com/hacksur/model-one", 48 | "volta": { 49 | "node": "22.15.0" 50 | }, 51 | "ava": { 52 | "files": [ 53 | "test/**/*.spec.ts" 54 | ], 55 | "extensions": [ 56 | "ts" 57 | ], 58 | "require": [ 59 | "ts-node/register" 60 | ], 61 | "timeout": "2m", 62 | "workerThreads": false, 63 | "environmentVariables": { 64 | "NODE_ENV": "test", 65 | "TS_NODE_PROJECT": "test/tsconfig.json" 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /scripts/checkpoint.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const crypto = require('crypto'); 4 | const { exec } = require('child_process'); 5 | 6 | // Correctly resolve the path to package.json 7 | const packageJsonPath = path.join(__dirname, '..', 'package.json'); 8 | const packageJson = require(packageJsonPath); 9 | 10 | // Function to generate a short hash 11 | function generateHash() { 12 | return crypto.randomBytes(6).toString('hex'); 13 | } 14 | 15 | // Extract the numbered version and append the hash 16 | const currentVersionParts = packageJson.version.split('-')[0]; 17 | const hash = generateHash(); 18 | const newVersion = `${currentVersionParts}-${hash}`; 19 | 20 | packageJson.version = newVersion; 21 | 22 | // Write the updated package.json back to file 23 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 24 | console.log(`Version updated to ${newVersion}`); 25 | 26 | // Get current branch name 27 | exec('git rev-parse --abbrev-ref HEAD', (error, stdout, stderr) => { 28 | if (error) { 29 | console.error(`Error getting current branch: ${error}`); 30 | return; 31 | } 32 | 33 | const currentBranch = stdout.trim(); 34 | 35 | // Run git commands to add changes and commit to the current branch 36 | const gitCommands = ` 37 | git add . && 38 | git commit -m "Checkpoint: ${newVersion}" && 39 | git push origin ${currentBranch} 40 | `; 41 | 42 | exec(gitCommands, (error, stdout, stderr) => { 43 | if (error) { 44 | console.error(`Error executing git commands: ${error}`); 45 | return; 46 | } 47 | if (stderr && !stderr.includes('Everything up-to-date')) { 48 | console.error(`Error in git operations: ${stderr}`); 49 | return; 50 | } 51 | console.log(`Checkpoint created successfully: ${newVersion}`); 52 | console.log(`Changes committed to branch: ${currentBranch}`); 53 | }); 54 | }); -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | declare const NotFoundError: () => null; 3 | declare class ModelError extends Error { 4 | errors: string[]; 5 | constructor(message: string, errors?: string[]); 6 | } 7 | export type SQLiteType = 'TEXT' | 'INTEGER' | 'REAL' | 'NUMERIC' | 'BLOB' | 'BOOLEAN' | 'TIMESTAMP' | 'DATE'; 8 | /** 9 | * Describes a single column within a database table for the ORM. 10 | * This definition is used by the Model to understand data types, 11 | * map data to/from the database, and generate validation schemas (e.g., via `getValidationSchema()`). 12 | */ 13 | export type ColumnType = 'string' | 'number' | 'boolean' | 'jsonb' | 'date'; 14 | /** 15 | * Column constraint types 16 | */ 17 | export type ConstraintType = 'PRIMARY KEY' | 'NOT NULL' | 'UNIQUE' | 'CHECK' | 'DEFAULT' | 'FOREIGN KEY'; 18 | /** 19 | * Column constraint definition 20 | */ 21 | export interface Constraint { 22 | type: ConstraintType; 23 | value?: string | number | boolean; 24 | } 25 | /** 26 | * Describes a single column within a database table for the ORM. 27 | * This definition is used by the Model to understand data types, 28 | * map data to/from the database, and generate validation schemas (e.g., via `getValidationSchema()`). 29 | */ 30 | export interface Column { 31 | /** The name of the column in the database table. */ 32 | name: string; 33 | /** 34 | * The JavaScript/TypeScript type this column should be mapped to in the application code 35 | * (e.g., 'string', 'number', 'boolean', 'Date', 'Object' for JSON). 36 | * This is typically defined using the `ColumnType` enum or a similar type definition. 37 | */ 38 | type: ColumnType; 39 | /** 40 | * The underlying SQLite data type for this column (e.g., 'TEXT', 'INTEGER', 'REAL', 'BLOB'). 41 | * This can be used by the ORM for type casting or if it assists in DDL generation. 42 | * However, DDL for specific constraints (like UNIQUE, CHECK) is generally managed 43 | * separately from this schema property when creating tables. 44 | */ 45 | sqliteType?: SQLiteType; 46 | /** 47 | * Indicates if the column is required (i.e., cannot be null). 48 | * Primarily used to generate validation rules (e.g., making a field mandatory in Joi schemas). 49 | * While this could inform a `NOT NULL` DDL property if `model-one` handles table creation, 50 | * this schema property's main effect is on application-level validation. 51 | */ 52 | required?: boolean; 53 | /** 54 | * Defines specific constraints or rules for the column, intended for ORM or application-level logic. 55 | * These are primarily used to generate validation rules (e.g., for Joi schemas via `getValidationSchema()`). 56 | * IMPORTANT: These `constraints` typically DO NOT directly translate into SQL DDL 57 | * constraints (like `UNIQUE`, `CHECK`, or `FOREIGN KEY`) automatically managed by `model-one`. 58 | * Such database-level constraints should usually be defined within the `CREATE TABLE` SQL statement. 59 | */ 60 | constraints?: Constraint[]; 61 | } 62 | type Columns = Column[]; 63 | /** 64 | * Defines the structure and properties of a database table for a Model. 65 | * This configuration is the blueprint used by a Model to interact with the database, 66 | * manage data serialization/deserialization, and generate validation schemas. 67 | * It dictates how the Model interprets and handles the table's data and structure. 68 | */ 69 | export interface SchemaConfigI { 70 | /** The actual name of the database table (e.g., 'users', 'products'). */ 71 | table_name: string; 72 | /** An array of `Column` definitions describing each column in the table. */ 73 | columns: Columns; 74 | /** 75 | * A list of column names (or sets of column names for composite uniques) that should hold unique values. 76 | * This is primarily leveraged for generating application-level validation logic 77 | * (e.g., informing uniqueness checks in Joi schemas or custom validation routines within the ORM). 78 | * IMPORTANT: This `uniques` property typically DOES NOT directly create SQL `UNIQUE` constraints 79 | * on the database table through `model-one`. Database-level unique constraints should generally be 80 | * defined within the `CREATE TABLE` SQL statement. 81 | */ 82 | uniques?: string[]; 83 | /** 84 | * If true, the Model will automatically manage `created_at` and `updated_at` timestamp columns. 85 | * These columns are typically of a DATETIME or TIMESTAMP compatible type and are updated by the ORM. 86 | */ 87 | timestamps?: boolean; 88 | /** 89 | * If true, the Model will employ a soft-delete strategy, usually by managing a `deleted_at` column. 90 | * Records are marked as deleted (by setting `deleted_at`) rather than being physically removed, 91 | * allowing for potential recovery or historical tracking. 92 | */ 93 | softDeletes?: boolean; 94 | } 95 | /** 96 | * Model data interface 97 | */ 98 | interface ModelDataI { 99 | id?: string; 100 | [key: string]: any; 101 | } 102 | declare class Form { 103 | schema: Joi.ObjectSchema; 104 | data: any; 105 | constructor(schema: Joi.ObjectSchema, data: any); 106 | validate(): void; 107 | } 108 | declare class Schema implements SchemaConfigI { 109 | table_name: string; 110 | columns: Columns; 111 | uniques: string[] | undefined; 112 | timestamps: boolean; 113 | softDeletes: boolean; 114 | constructor(props: { 115 | table_name: string; 116 | columns: Columns; 117 | uniques?: string[]; 118 | timestamps?: boolean; 119 | softDeletes?: boolean; 120 | }); 121 | } 122 | declare class Model { 123 | id: string | null; 124 | schema: SchemaConfigI; 125 | data: ModelDataI; 126 | constructor(schema?: SchemaConfigI, props?: ModelDataI); 127 | update(partialData: Partial, env: any): Promise; 128 | /** 129 | * Deletes the current model instance from the database. 130 | * @param env - The database environment/connection object 131 | * @returns The result of the delete operation 132 | * @throws {ModelError} If the instance is missing an ID 133 | */ 134 | delete(env: any): Promise; 135 | /** 136 | * Maps JavaScript types to SQLite types 137 | */ 138 | private static getDefaultSQLiteType; 139 | /** 140 | * Processes a value based on its column type for database storage 141 | */ 142 | private static processValueForStorage; 143 | /** 144 | * Processes a database value based on column type for JavaScript usage 145 | */ 146 | private static processValueFromStorage; 147 | private static deserializeData; 148 | private static serializeData; 149 | private static createModelInstance; 150 | static create({ data }: any, env: any): Promise; 151 | static update({ data }: any, env: any): Promise; 152 | static delete(id: string, env: any): Promise<{ 153 | message: string; 154 | }>; 155 | static restore(id: string, env: any): Promise<{ 156 | message: string; 157 | data?: undefined; 158 | } | { 159 | message: string; 160 | data: any; 161 | }>; 162 | static all(env: any, includeDeleted?: Boolean): Promise; 163 | static findOne(column: string, value: string, env: any, includeDeleted?: Boolean): Promise; 164 | static findBy(column: string, value: string, env: any, includeDeleted?: Boolean): Promise; 165 | static findById(id: string, env: any, includeDeleted?: Boolean): Promise; 166 | static query(sql: string, env: any, params?: any[]): Promise<{ 167 | success: boolean; 168 | message: string; 169 | results?: undefined; 170 | } | { 171 | success: boolean; 172 | results: any; 173 | message?: undefined; 174 | }>; 175 | /** 176 | * Saves the current model instance to the database. 177 | * If the instance has an ID (from `this.id` or `this.data.id`), it dispatches to the static `update()` method. 178 | * Otherwise, it dispatches to the static `create()` method. 179 | * The instance's `data` and `id` properties are updated with the result from the database operation. 180 | * @param env - The database environment/connection object. 181 | * @returns {Promise} A promise that resolves to the current instance (this) after being updated, 182 | * or null if the operation fails or returns no data. 183 | * @throws {ModelError} Can be thrown by underlying create/update operations (e.g., validation). 184 | */ 185 | save(env: any): Promise; 186 | } 187 | export { Form, Schema, Model, NotFoundError, ModelDataI, ModelError }; 188 | -------------------------------------------------------------------------------- /MIGRATION_GUIDE_AVA6_NODE22.md: -------------------------------------------------------------------------------- 1 | # Migration Guide: AVA 6.x and Node.js 22 2 | 3 | ## Introduction 4 | 5 | This guide provides instructions and considerations for migrating projects using older versions of AVA to AVA 6.x (specifically targeting AVA 6.3.0 as used in recent fixes) and Node.js 22. It covers common configuration changes, potential issues, and best practices learned from a real-world migration scenario involving TypeScript, Miniflare, and D1 databases. 6 | 7 | ## Prerequisites 8 | 9 | * Node.js 22 installed on your development machine. 10 | * Your project should be using an older version of AVA. 11 | * If your project uses TypeScript (as this one does), ensure your `typescript` version is compatible (typically latest stable is fine). 12 | 13 | ## Key Changes and Migration Steps 14 | 15 | 1. **Update AVA Version**: 16 | * Modify your `package.json` to update AVA. For example, using npm: 17 | ```bash 18 | npm install --save-dev ava@^6.3.0 19 | ``` 20 | * Or using yarn: 21 | ```bash 22 | yarn add --dev ava@^6.3.0 23 | ``` 24 | 25 | 2. **Node.js 22 Compatibility**: 26 | * Node.js 22 brings a newer V8 engine. While most modern JavaScript code runs without issues, be mindful of potential subtle behavioral changes or performance differences. 27 | * If your project uses native Node.js modules, they might require recompilation to be compatible with Node 22. 28 | 29 | 3. **TypeScript Configuration (Critical)**: 30 | * **`ts-node` Dependency**: For projects using TypeScript for tests (i.e., test files are `.ts` and not pre-compiled to JavaScript), AVA 6+ generally requires `ts-node` for on-the-fly transpilation. 31 | * Install `ts-node` if you haven't already: 32 | ```bash 33 | npm install --save-dev ts-node 34 | # or 35 | yarn add --dev ts-node 36 | ``` 37 | * **AVA Configuration for TypeScript**: It's recommended to configure AVA directly in your `package.json` or an `ava.config.js` (`.mjs` for ESM) file. For `package.json`: 38 | ```json 39 | { 40 | // ... other package.json content 41 | "ava": { 42 | "files": [ 43 | "test/**/*.spec.ts" 44 | ], 45 | "extensions": [ 46 | "ts" 47 | ], 48 | "require": [ 49 | "ts-node/register" 50 | ], 51 | "environmentVariables": { 52 | "TS_NODE_PROJECT": "./tsconfig.json" 53 | } 54 | } 55 | } 56 | ``` 57 | * `files`: (Optional but good practice) Specify glob patterns for your test files. 58 | * `extensions: ["ts"]`: Essential. Tells AVA to recognize and process `.ts` files as tests. 59 | * `require: ["ts-node/register"]`: Crucial. This hooks `ts-node` into Node.js's module loading system, enabling it to transpile TypeScript files when they are required by AVA. 60 | * `environmentVariables: { "TS_NODE_PROJECT": "./tsconfig.json" }`: Highly recommended. This tells `ts-node` which `tsconfig.json` file to use for compilation options. Ensure this path is correct for your project structure. You might have a root `tsconfig.json` or a test-specific one. 61 | 62 | 4. **ESM vs. CommonJS**: 63 | * AVA 6 offers robust support for ECMAScript Modules (ESM). If your project or tests are written as ESM (e.g., using `import`/`export` syntax natively, `package.json` has `"type": "module"`, or files use `.mjs` extension), AVA should handle it well. 64 | * If your project (like the one this guide is based on) uses CommonJS for tests or relies on `ts-node` to transpile TypeScript that might be CJS-like, the `ts-node/register` setup is generally the correct approach. 65 | 66 | 5. **Test Code Adjustments (Learnings from Experience)**: 67 | * **Database Schema Execution (e.g., D1/Miniflare)**: 68 | * When passing SQL schema strings (e.g., `CREATE TABLE ...`) from variables to database execution methods like `db.exec()`, ensure the argument is a single string. If your schema is defined as `const schema = ['CREATE TABLE...'];`, you must pass `schema[0]` (i.e., `db.exec(schema[0])`). 69 | * **Whitespace and Newlines**: D1 (and its Miniflare emulation) can be surprisingly strict about whitespace and newlines in SQL strings, especially for `CREATE TABLE` statements. This can lead to errors like `D1_EXEC_ERROR: Error in line 1: ... (: incomplete input: SQLITE_ERROR`. 70 | * **Always trim your SQL strings**: `await db.exec(schema[0].trim());` 71 | * For maximum reliability, consider formatting your `CREATE TABLE` statements as a **single continuous line of SQL** without any internal newlines, then trim. 72 | * **API Method Signatures**: Pay close attention to the expected structure of arguments for methods, especially after library upgrades. A common pattern that caused issues was a method expecting an object wrapper, e.g., `YourModel.update({ data: payloadObject }, env)` instead of `YourModel.update(payloadObject, env)`. Mismatches can lead to `TypeError`s like `Cannot destructure property 'X' of 'Y' as it is undefined`. 73 | 74 | 6. **Miniflare/Cloudflare Workers Specifics** (If relevant): 75 | * Ensure your Miniflare version is compatible with Node 22 and AVA 6. Check Miniflare's release notes. 76 | * The D1 SQL parsing nuances mentioned above are particularly relevant when working with Miniflare. 77 | 78 | ## Running and Debugging Tests 79 | 80 | * **Standard Command**: `npx ava` or `yarn ava`. 81 | * **Suppress Node Warnings**: `NODE_NO_WARNINGS=1 npx ava` can make test output cleaner. 82 | * **Debugging Tips**: 83 | * Use `console.log()` liberally within test setup (`beforeEach`, `test.before`), test bodies, and teardown (`afterEach`, `test.after`) to inspect variable states and execution flow. 84 | * Run specific test files to isolate issues: `npx ava test/MySpecificTest.spec.ts`. 85 | * AVA's `--verbose` flag can sometimes provide additional diagnostic information. 86 | 87 | ## Troubleshooting Common Issues 88 | 89 | * **`SyntaxError: Cannot use import statement outside a module` / `ReferenceError: require is not defined`**: Usually indicates a mismatch in how AVA expects modules (ESM/CJS) versus how your files are written or configured. Check `package.json` for `"type": "module"` and ensure your AVA config (`extensions`, `require`) aligns. 90 | * **`TypeError: ... is not a function` or `... undefined`**: Often due to incorrect module imports/exports, API signature changes in dependencies, or issues with how `ts-node` transpiles code. 91 | * **`SQLITE_ERROR` or `D1_EXEC_ERROR`**: Specific to database interactions. Re-check your SQL syntax, how it's being passed to `db.exec()`, and the D1/Miniflare specific points above (trimming, single-line SQL). 92 | * **Tests Not Being Found**: Verify your AVA configuration, especially `files` patterns and `extensions`. Ensure `extensions: ["ts"]` is present for TypeScript projects. 93 | 94 | ## Prompt for Future Updates or Similar Migrations 95 | 96 | When planning to update this project further or migrate other projects to similar environments (e.g., new AVA versions, Node.js updates, or changes in core dependencies like Miniflare), consider the following: 97 | 98 | **DO:** 99 | 100 | * ✅ **Consult Changelogs**: Carefully review the official changelogs for AVA, Node.js, TypeScript, `ts-node`, and any other critical libraries (like Miniflare) for breaking changes, new features, and deprecations between your current version and the target version. 101 | * ✅ **Incremental Updates**: If feasible, update dependencies one major component at a time (e.g., Node first, then AVA, then others) and run tests after each significant change to isolate potential issues more easily. 102 | * ✅ **Verify TypeScript Configuration**: For TypeScript projects, triple-check the AVA configuration related to `ts-node` in `package.json` (or `ava.config.js`). Ensure `extensions: ["ts"]` and `require: ["ts-node/register"]` are correctly set. 103 | * ✅ **Confirm `TS_NODE_PROJECT` Path**: Make sure the `TS_NODE_PROJECT` environment variable (if used in AVA config) accurately points to the `tsconfig.json` file that `ts-node` should use for transpiling tests. 104 | * ✅ **Thoroughly Test Database Interactions**: Pay special attention to tests involving database schema creation and data manipulation, especially if using D1/Miniflare, due to potential SQL parsing sensitivities (trimming, single-line SQL statements). 105 | * ✅ **Scrutinize Method Signatures**: Be vigilant for changes in how methods (both in your codebase and in third-party libraries) expect their arguments. The `Model.update({ data: ... })` issue is a prime example of a subtle change that can break tests. 106 | * ✅ **Isolate Test Failures**: When tests fail, try to run the failing test file individually to narrow down the scope of the problem. 107 | 108 | **DO NOT:** 109 | 110 | * ❌ **Assume Seamless Compatibility**: Do not assume that test code will work without any modifications, especially with major version bumps in core components like AVA, Node.js, or significant libraries. 111 | * ❌ **Ignore Deprecation Warnings**: If your current setup logs deprecation warnings from libraries, address these *before* attempting a major migration, as deprecated features are often removed in new versions. 112 | * ❌ **Overlook Environment Differences**: Do not forget to test on a clean environment or your CI/CD pipeline after achieving local success, as environmental inconsistencies can hide or reveal issues. 113 | * ❌ **Modify Multiple Things Blindly**: Avoid changing many parts of your test setup or codebase simultaneously without a clear hypothesis if tests start failing, as this makes it harder to pinpoint the root cause. 114 | -------------------------------------------------------------------------------- /lib/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAEA,MAAM,aAAa,GAAG,GAAG,EAAE;IACzB,OAAO,IAAI,CAAA;AACb,CAAC,CAAA;AAwpBC,sCAAa;AAtpBf,MAAM,UAAW,SAAQ,KAAK;IAG5B,YAAY,OAAe,EAAE,SAAmB,EAAE;QAChD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,YAAY,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAgpBC,gCAAU;AAphBZ,MAAM,IAAI;IAIR,YACE,MAA6B,EAC7B,IAAS;QAET,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAA;QACrB,IAAI,CAAC,QAAQ,EAAE,CAAA;IACjB,CAAC;IAED,0DAA0D;IAC1D,QAAQ;QACN,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACjD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,MAAM,IAAI,UAAU,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAA;QAClF,CAAC;IACH,CAAC;CACF;AA2fC,oBAAI;AAzfN,MAAM,MAAM;IAOV,YAAY,KAMX;QACC,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC,UAAU,CAAA;QAClC,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;QAC5B,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;QAC5B,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC,UAAU,IAAI,IAAI,CAAA,CAAC,6CAA6C;QACxF,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,IAAI,KAAK,CAAA;IAC/C,CAAC;CACF;AAseC,wBAAM;AApeR,MAAM,KAAK;IAKT,YAAY,MAAsB,EAAE,KAAkB;QACpD,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,EAAE,IAAI,IAAI,CAAA;QAC3B,IAAI,CAAC,MAAM,GAAG,MAAM,IAAI,EAAmB,CAAA,CAAC,+BAA+B;QAC3E,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,EAAE,CAAA;IACzB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAAgC,EAAE,GAAQ;QACrD,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC;YACnB,MAAM,IAAI,UAAU,CAAC,gDAAgD,CAAC,CAAC;QACzE,CAAC;QAED,MAAM,aAAa,GAAG,EAAE,GAAG,IAAI,CAAC,IAAI,EAAE,GAAG,WAAW,EAAE,CAAC;QACvD,MAAM,IAAI,GAAG,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;QAErC,MAAM,SAAS,GAAG,IAAI,CAAC,WAA2B,CAAC;QACnD,MAAM,sBAAsB,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAgB,CAAC;QAEhF,IAAI,sBAAsB,IAAI,sBAAsB,CAAC,IAAI,EAAE,CAAC;YAC1D,IAAI,CAAC,IAAI,GAAG,EAAE,GAAG,sBAAsB,CAAC,IAAI,EAAE,CAAC;YAC/C,IAAI,CAAC,EAAE,GAAG,sBAAsB,CAAC,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC;QACd,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,+EAA+E,EAAE,sBAAsB,CAAC,CAAC;YACvH,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,MAAM,CAAC,GAAQ;QACnB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC;YACnB,MAAM,IAAI,UAAU,CAAC,gDAAgD,CAAC,CAAC;QACzE,CAAC;QAED,MAAM,SAAS,GAAG,IAAI,CAAC,WAA2B,CAAC;QACnD,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;QAEzD,kDAAkD;QAClD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED;;OAEG;IACK,MAAM,CAAC,oBAAoB,CAAC,UAAsB;QACxD,QAAQ,UAAU,EAAE,CAAC;YACnB,KAAK,QAAQ;gBACX,OAAO,MAAM,CAAC;YAChB,KAAK,QAAQ;gBACX,OAAO,MAAM,CAAC;YAChB,KAAK,SAAS;gBACZ,OAAO,SAAS,CAAC,CAAC,gCAAgC;YACpD,KAAK,OAAO;gBACV,OAAO,MAAM,CAAC,CAAC,qCAAqC;YACtD,KAAK,MAAM;gBACT,OAAO,MAAM,CAAC,CAAC,8BAA8B;YAC/C;gBACE,OAAO,MAAM,CAAC;QAClB,CAAC;IACH,CAAC;IAED;;OAEG;IACK,MAAM,CAAC,sBAAsB,CAAC,KAAU,EAAE,UAAsB;QACtE,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YAC1C,IAAI,UAAU,KAAK,OAAO,EAAE,CAAC;gBAC3B,OAAO,MAAM,CAAC;YAChB,CAAC;YACD,OAAO,IAAI,CAAC;QACd,CAAC;QAED,QAAQ,UAAU,EAAE,CAAC;YACnB,KAAK,SAAS;gBACZ,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACvB,KAAK,OAAO;gBACV,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;YAC/B,KAAK,MAAM;gBACT,OAAO,KAAK,YAAY,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;YAC7D;gBACE,OAAO,KAAK,CAAC;QACjB,CAAC;IACH,CAAC;IAED;;OAEG;IACK,MAAM,CAAC,uBAAuB,CAAC,KAAU,EAAE,UAAsB;QACvE,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YAC1C,OAAO,IAAI,CAAC;QACd,CAAC;QAED,QAAQ,UAAU,EAAE,CAAC;YACnB,KAAK,SAAS;gBACZ,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC;YACxB,KAAK,QAAQ;gBACX,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;YACvB,KAAK,OAAO;gBACV,IAAI,KAAK,KAAK,EAAE,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;oBACrC,OAAO,IAAI,CAAC;gBACd,CAAC;gBACD,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YAC/D,KAAK,MAAM;gBACT,OAAO,KAAK,CAAC,CAAC,4CAA4C;YAC5D;gBACE,OAAO,KAAK,CAAC;QACjB,CAAC;IACH,CAAC;IAEO,MAAM,CAAC,eAAe,CAAC,IAAS,EAAE,MAAqB;QAC7D,MAAM,EAAE,EAAE,EAAE,GAA0B,IAAI,CAAC;QAE3C,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;YACjB,MAAM,IAAI,GAAa,CAAC,IAAI,CAAC,CAAC;YAC9B,MAAM,MAAM,GAAU,CAAC,GAAG,MAAM,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;YAEjD,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE;gBAChC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,SAAS,EAAE,CAAC;oBACpC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;oBACvB,MAAM,cAAc,GAAG,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;oBAEnF,IAAI,cAAc,KAAK,IAAI,EAAE,CAAC;wBAC5B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;oBACtB,CAAC;yBAAM,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;wBAC9C,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,QAAQ,EAAE,CAAC,CAAC;oBACzC,CAAC;yBAAM,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;wBAC9C,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;oBAC9B,CAAC;yBAAM,CAAC;wBACN,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC;oBAC9C,CAAC;gBACH,CAAC;YACH,CAAC,CAAC,CAAC;YAEH,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE;gBACrC,OAAO,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;YACrC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAEd,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAC,CAAC;QAC3D,CAAC;aACI,CAAC;YACJ,MAAM,UAAU,GAAa,EAAE,CAAC;YAEhC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE;gBAChC,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;oBAAE,OAAO;gBAEjC,IAAI,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;oBAC5D,MAAM,cAAc,GAAG,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;oBAEnF,IAAI,cAAc,KAAK,IAAI,EAAE,CAAC;wBAC5B,UAAU,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,SAAS,CAAC,CAAC;oBAC3C,CAAC;yBAAM,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;wBAC9C,UAAU,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,MAAM,cAAc,EAAE,CAAC,CAAC;oBACxD,CAAC;yBAAM,CAAC;wBACN,UAAU,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,OAAO,cAAc,GAAG,CAAC,CAAC;oBAC1D,CAAC;gBACH,CAAC;YACH,CAAC,CAAC,CAAC;YAEH,OAAO,EAAE,UAAU,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,CAAC;IACH,CAAC;IAEO,MAAM,CAAC,aAAa,CAAC,IAAS,EAAE,MAAqB;QAC3D,MAAM,MAAM,GAAG,EAAE,GAAG,IAAI,EAAE,CAAC;QAE3B,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE;YAChC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,SAAS,EAAE,CAAC;gBACpC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;YACrF,CAAC;iBAAM,CAAC;gBACN,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;YAC7B,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;YAChC,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,EAAE,CAAC;gBAC9B,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;YACrB,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,OAAO,MAAM,CAAC;IAChB,CAAC;IAEO,MAAM,CAAC,mBAAmB,CAAC,IAAS,EAAE,iBAAgC;QAC5E,MAAM,QAAQ,GAAG,IAAI,IAAI,CAAC,iBAAiB,CAAC,CAAC;QAC7C,QAAQ,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;QAEtB,QAAQ,CAAC,IAAI,GAAG,EAAE,CAAC;QACnB,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;YAC9B,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QACjC,CAAC,CAAC,CAAC;QACH,IAAI,IAAI,CAAC,EAAE,EAAE,CAAC;YACZ,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;QAC7B,CAAC;QAED,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,EAAO,EAAE,GAAQ;QACzC,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAC9B,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAE5D,IAAI,KAAK,GAAG,eAAe,MAAM,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QACxD,IAAI,UAAU,GAAG,UAAU,MAAM,EAAE,CAAC;QAEpC,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;YACtB,KAAK,IAAI,0BAA0B,CAAC;YACpC,UAAU,IAAI,oCAAoC,CAAC;QACrD,CAAC;QAED,KAAK,IAAI,KAAK,UAAU,gBAAgB,CAAC;QAEzC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAC,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE3D,IAAI,OAAO,IAAI,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YACrC,MAAM,QAAQ,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YACnC,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;YACjE,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,EAAO,EAAE,GAAQ;QACzC,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAC9B,MAAM,EAAE,EAAE,EAAE,GAAG,IAAI,CAAC;QAEpB,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;YACvD,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC1D,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3C,OAAO,CAAC,IAAI,CAAC,oDAAoD,EAAE,EAAE,CAAC,CAAC;YACvE,qFAAqF;YACrF,mEAAmE;YACnE,OAAO,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,uCAAuC;QAC/E,CAAC;QAED,IAAI,KAAK,GAAG,UAAU,MAAM,CAAC,UAAU,QAAQ,UAAU,EAAE,CAAC;QAE5D,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;YACtB,KAAK,IAAI,gCAAgC,CAAC;QAC5C,CAAC;QAED,KAAK,IAAI,cAAc,EAAE,gBAAgB,CAAC;QAE1C,MAAM,EAAE,OAAO,EAAE,OAAO,EAAC,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE3D,IAAI,OAAO,IAAI,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YACrC,MAAM,QAAQ,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YACnC,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,uBAAuB,CAAC,CAAC;YACjE,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,GAAQ;QACtC,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAAE,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAC,CAAC;QAEtD,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;YACvB,MAAM,KAAK,GAAG,UAAU,MAAM,CAAC,UAAU;;gCAEf,EAAE,IAAI,CAAC;YACjC,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;YAEnD,OAAO,OAAO,CAAC,CAAC;gBACd,EAAE,OAAO,EAAE,UAAU,EAAE,gBAAgB,MAAM,CAAC,UAAU,0BAA0B,EAAE;gBACpF,CAAC,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,iCAAiC,MAAM,CAAC,UAAU,GAAG,EAAE,CAAC;QACrF,CAAC;aACI,CAAC;YACJ,MAAM,KAAK,GAAG,eAAe,MAAM,CAAC,UAAU,cAAc,EAAE,IAAI,CAAC;YACnE,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;YAEnD,OAAO,OAAO,CAAC,CAAC;gBACd,EAAE,OAAO,EAAE,UAAU,EAAE,gBAAgB,MAAM,CAAC,UAAU,kCAAkC,EAAE;gBAC5F,CAAC,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,iCAAiC,MAAM,CAAC,UAAU,GAAG,EAAE,CAAC;QACrF,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAU,EAAE,GAAQ;QACvC,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAAE,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAC,CAAC;QAEtD,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;YACxB,OAAO,EAAE,OAAO,EAAE,2CAA2C,MAAM,CAAC,UAAU,GAAG,EAAE,CAAC;QACtF,CAAC;QAED,MAAM,KAAK,GAAG,UAAU,MAAM,CAAC,UAAU;;8BAEf,EAAE,gBAAgB,CAAC;QAC7C,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE5D,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACjD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,iCAAiC,MAAM,CAAC,UAAU,GAAG,EAAE,CAAC;QACxF,CAAC;QAED,MAAM,QAAQ,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QACnC,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAE5D,OAAO;YACL,OAAO,EAAE,UAAU,EAAE,gBAAgB,MAAM,CAAC,UAAU,mCAAmC;YACzF,IAAI,EAAE,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC;SACvD,CAAC;IACJ,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,GAAQ,EAAE,cAAwB;QACjD,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,KAAK,GAAG,iBAAiB,MAAM,CAAC,UAAU,EAAE,CAAC;QAEjD,IAAI,MAAM,CAAC,WAAW,IAAI,CAAC,cAAc,EAAE,CAAC;YAC1C,KAAK,IAAI,2BAA2B,CAAC;QACvC,CAAC;QAED,KAAK,IAAI,GAAG,CAAC;QAEb,MAAM,EAAE,OAAO,EAAE,OAAO,EAAC,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE3D,IAAG,CAAC,OAAO;YAAE,OAAO,EAAE,CAAC;QAEvB,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClC,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAW,EAAE,EAAE;gBACjC,MAAM,QAAQ,GAAG,EAAE,GAAG,MAAM,EAAE,CAAC;gBAC/B,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;YAC1D,CAAC,CAAC,CAAC;QACL,CAAC;aAAM,CAAC;YACN,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAc,EAAE,KAAa,EAAE,GAAQ,EAAE,cAAwB;QACpF,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,KAAK,GAAG,iBAAiB,MAAM,CAAC,UAAU,UAAU,MAAM,KAAK,KAAK,GAAG,CAAC;QAE5E,IAAI,MAAM,CAAC,WAAW,IAAI,CAAC,cAAc,EAAE,CAAC;YAC1C,KAAK,IAAI,yBAAyB,CAAC;QACrC,CAAC;QAED,KAAK,IAAI,WAAW,CAAC;QAErB,MAAM,EAAE,OAAO,EAAE,OAAO,EAAC,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE3D,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/C,OAAO,IAAI,CAAC;QAChB,CAAC;QAED,MAAM,QAAQ,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QACnC,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,KAAa,EAAE,GAAQ,EAAE,cAAwB;QACnF,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,KAAK,GAAG,iBAAiB,MAAM,CAAC,UAAU,UAAU,MAAM,KAAK,KAAK,GAAG,CAAC;QAE5E,IAAI,MAAM,CAAC,WAAW,IAAI,CAAC,cAAc,EAAE,CAAC;YAC1C,KAAK,IAAI,yBAAyB,CAAC;QACrC,CAAC;QAED,KAAK,IAAI,GAAG,CAAC;QAEb,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE5D,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO;YAAE,OAAO,EAAE,CAAC;QAEpC,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAW,EAAE,EAAE;YACjC,MAAM,QAAQ,GAAG,EAAE,GAAG,MAAM,EAAE,CAAC;YAC/B,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAC1D,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAK,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;IACnC,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAU,EAAE,GAAQ,EAAE,cAAwB;QAClE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,IAAI,EAAE,CAAC;QAE9B,IAAI,KAAK,GAAG,iBAAiB,MAAM,CAAC,UAAU,cAAc,EAAE,GAAG,CAAC;QAElE,IAAI,MAAM,CAAC,WAAW,IAAI,CAAC,cAAc,EAAE,CAAC;YAC1C,KAAK,IAAI,yBAAyB,CAAC;QACrC,CAAC;QAED,KAAK,IAAI,WAAW,CAAC;QAErB,MAAM,EAAE,OAAO,EAAE,OAAO,EAAC,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC;QAE3D,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/C,OAAO,IAAI,CAAC;QAChB,CAAC;QAED,MAAM,QAAQ,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QACnC,MAAM,cAAc,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC5D,OAAO,IAAI,CAAC,mBAAmB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,GAAW,EAAE,GAAQ,EAAE,MAAc;QACtD,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC;QAClE,IAAI,CAAC,OAAO;YAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC;QACjE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IACpC,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,IAAI,CAAC,GAAQ;QACjB,OAAO,CAAC,GAAG,CAAC,6BAA6B,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;QACpD,OAAO,CAAC,GAAG,CAAC,+BAA+B,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAExE,MAAM,SAAS,GAAG,IAAI,CAAC,WAA2B,CAAC;QAEnD,qFAAqF;QACrF,IAAI,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;YAC3D,OAAO,CAAC,GAAG,CAAC,iDAAiD,CAAC,CAAC;YAC/D,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;QACzB,CAAC;QAED,oFAAoF;QACpF,MAAM,OAAO,GAAG,EAAE,IAAI,EAAE,EAAE,GAAG,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,6BAA6B;QACzE,OAAO,CAAC,GAAG,CAAC,yCAAyC,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;QAEhF,IAAI,cAAc,GAAiB,IAAI,CAAC;QACxC,IAAI,aAAa,GAAiC,MAAM,CAAC;QAEzD,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,uCAAuC;YACzD,aAAa,GAAG,QAAQ,CAAC;YACzB,OAAO,CAAC,GAAG,CAAC,gCAAgC,IAAI,CAAC,IAAI,CAAC,EAAE,0BAA0B,CAAC,CAAC;YACpF,cAAc,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QACxD,CAAC;aAAM,CAAC,CAAC,mCAAmC;YAC1C,aAAa,GAAG,QAAQ,CAAC;YACzB,OAAO,CAAC,GAAG,CAAC,yDAAyD,CAAC,CAAC;YACvE,iFAAiF;YACjF,2CAA2C;YAC3C,IAAI,OAAO,CAAC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,SAAS,CAAC,EAAE,CAAC;gBACrG,OAAO,CAAC,GAAG,CAAC,8DAA8D,CAAC,CAAC;gBAC5E,OAAO,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,CAAC;YACD,cAAc,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QACxD,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,mCAAmC,aAAa,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC;QAEjG,IAAI,cAAc,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC;YAC1C,OAAO,CAAC,GAAG,CAAC,uEAAuE,CAAC,CAAC;YACrF,6DAA6D;YAC7D,IAAI,CAAC,IAAI,GAAG,EAAE,GAAG,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,uBAAuB;YAC/D,IAAI,CAAC,EAAE,GAAG,cAAc,CAAC,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,CAAC,qBAAqB;YAE/D,OAAO,CAAC,GAAG,CAAC,8BAA8B,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;YACrD,OAAO,CAAC,GAAG,CAAC,gCAAgC,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;YACzE,OAAO,IAAI,CAAC,CAAC,uCAAuC;QACtD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,uBAAuB,aAAa,wCAAwC,CAAC,CAAC;YAC5F,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;CACF;AAKC,sBAAK"} -------------------------------------------------------------------------------- /test/ColumnTypes.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import Joi from 'joi'; 3 | import { Miniflare } from 'miniflare'; 4 | import { Model, Schema, type SchemaConfigI, Form, type Column } from '../src'; 5 | 6 | // Test database schema 7 | export const schema = [ 8 | `CREATE TABLE advanced_entities (id text PRIMARY KEY, string_value text, number_value real, integer_value integer, boolean_value integer, json_value text, date_value text, deleted_at datetime, created_at datetime, updated_at datetime);` 9 | ]; 10 | 11 | // Define column types for the schema 12 | const columns: Column[] = [ 13 | { name: 'id', type: 'string', constraints: [{ type: 'PRIMARY KEY' }] }, 14 | { name: 'string_value', type: 'string', sqliteType: 'TEXT' }, 15 | { name: 'number_value', type: 'number', sqliteType: 'REAL' }, 16 | { name: 'integer_value', type: 'number', sqliteType: 'INTEGER' }, 17 | { name: 'boolean_value', type: 'boolean', sqliteType: 'INTEGER' }, 18 | { name: 'json_value', type: 'jsonb', sqliteType: 'TEXT' }, 19 | { name: 'date_value', type: 'date', sqliteType: 'TEXT' } 20 | ]; 21 | 22 | // Schema configuration 23 | const advancedEntitySchema: SchemaConfigI = new Schema({ 24 | table_name: 'advanced_entities', 25 | columns, 26 | timestamps: true, 27 | softDeletes: true 28 | }); 29 | 30 | // Define interfaces for our model 31 | interface AdvancedEntityDataI { 32 | id?: string; 33 | string_value?: string; 34 | number_value?: number; 35 | integer_value?: number; 36 | boolean_value?: boolean; 37 | json_value?: Record; 38 | date_value?: Date | string; 39 | } 40 | 41 | interface AdvancedEntityI extends Model { 42 | data: AdvancedEntityDataI; 43 | } 44 | 45 | // Joi validation schema for form validation 46 | const advancedEntityJoiSchema = Joi.object({ 47 | id: Joi.string(), 48 | string_value: Joi.string(), 49 | number_value: Joi.number(), 50 | integer_value: Joi.number().integer(), 51 | boolean_value: Joi.boolean(), 52 | json_value: Joi.object(), 53 | date_value: Joi.date() 54 | }); 55 | 56 | // Form class for validation 57 | export class AdvancedEntityForm extends Form { 58 | constructor(data: AdvancedEntityI) { 59 | super(advancedEntityJoiSchema, data); 60 | } 61 | } 62 | 63 | // Model class that extends the base Model 64 | class AdvancedEntity extends Model implements AdvancedEntityI { 65 | data: AdvancedEntityDataI; 66 | 67 | constructor(props: AdvancedEntityDataI = {}) { 68 | super(advancedEntitySchema); 69 | this.data = props || {}; 70 | } 71 | 72 | // Static method to ensure proper model initialization 73 | static async create(form: any, env: any) { 74 | return super.create(form, env); 75 | } 76 | 77 | static async update(data: any, env: any) { 78 | return super.update(data, env); 79 | } 80 | 81 | static async findById(id: string, env: any, complete: boolean = false) { 82 | return super.findById(id, env, complete); 83 | } 84 | 85 | static async findOne(column: string, value: string, env: any) { 86 | return super.findOne(column, value, env); 87 | } 88 | 89 | static async all(env: any) { 90 | return super.all(env); 91 | } 92 | 93 | static async delete(id: string, env: any) { 94 | return super.delete(id, env); 95 | } 96 | 97 | static async query(query: string, env: any) { 98 | return super.query(query, env); 99 | } 100 | } 101 | 102 | // Helper function to create an entity with the given data 103 | async function createEntity(data: AdvancedEntityDataI, binding: any): Promise { 104 | const entity = new AdvancedEntity(data); 105 | const form = new AdvancedEntityForm(entity); 106 | return AdvancedEntity.create(form, binding); 107 | } 108 | 109 | 110 | // Store Miniflare instances to clean up later 111 | const miniflares: any[] = []; 112 | 113 | test.beforeEach(async (t) => { 114 | try { 115 | // Create a Miniflare instance with D1 116 | const mf = new Miniflare({ 117 | modules: true, 118 | script: 'export default {};', 119 | d1Databases: ['TEST_DB'], 120 | }); 121 | 122 | // Get the D1 database 123 | const db = await mf.getD1Database('TEST_DB'); 124 | 125 | // Create users table with simplified SQL syntax for Miniflare v4 126 | await db.exec(schema[0].trim()); 127 | 128 | // Store context for the test` 129 | t.context = { db, mf }; 130 | 131 | // Store instance for cleanup 132 | miniflares.push(mf); 133 | 134 | console.log('✅ Test database initialized with users schema'); 135 | } catch (error) { 136 | console.error('❌ Error in test setup:', error); 137 | throw error; 138 | } 139 | }); 140 | 141 | // Cleanup Miniflare instances after tests 142 | test.after.always(() => { 143 | for (const mf of miniflares) { 144 | if (mf && typeof mf.dispose === 'function') { 145 | mf.dispose(); 146 | } 147 | } 148 | }); 149 | 150 | 151 | // Column Type Tests 152 | test('string - should store and retrieve string values correctly', async (t) => { 153 | const { db: binding }: any = t.context; 154 | const entity = await createEntity({ string_value: 'Test String' }, binding); 155 | 156 | t.is(entity.data.string_value, 'Test String'); 157 | t.truthy(entity.data.id); // Should have an auto-generated UUID 158 | 159 | // Verify retrieval 160 | const retrieved = await AdvancedEntity.findById(entity.data.id, binding); 161 | t.not(retrieved, null); 162 | if (retrieved) { 163 | t.is(retrieved.data.string_value, 'Test String'); 164 | } 165 | }); 166 | 167 | test('number - should store and retrieve number values correctly', async (t) => { 168 | const { db: binding }: any = t.context; 169 | const entity = await createEntity({ 170 | number_value: 123.45, 171 | integer_value: 42 172 | }, binding); 173 | 174 | // Verify values 175 | t.is(entity.data.number_value, 123.45); 176 | t.is(entity.data.integer_value, 42); 177 | 178 | // Verify correct type conversion - numbers should be numbers not strings 179 | t.is(typeof entity.data.number_value, 'number'); 180 | t.is(typeof entity.data.integer_value, 'number'); 181 | 182 | // Verify retrieval 183 | const retrieved = await AdvancedEntity.findById(entity.data.id, binding); 184 | t.not(retrieved, null); 185 | if (retrieved) { 186 | t.is(retrieved.data.number_value, 123.45); 187 | t.is(retrieved.data.integer_value, 42); 188 | } 189 | }); 190 | 191 | test('boolean - should store and retrieve boolean values correctly', async (t) => { 192 | const { db: binding }: any = t.context; 193 | 194 | // Test true value 195 | const trueEntity = await createEntity({ boolean_value: true }, binding); 196 | t.is(trueEntity.data.boolean_value, true); 197 | t.is(typeof trueEntity.data.boolean_value, 'boolean'); 198 | 199 | // Test false value 200 | const falseEntity = await createEntity({ boolean_value: false }, binding); 201 | t.is(falseEntity.data.boolean_value, false); 202 | t.is(typeof falseEntity.data.boolean_value, 'boolean'); 203 | 204 | // Verify retrieval 205 | if (trueEntity.data.id) { 206 | const retrievedTrue = await AdvancedEntity.findById(trueEntity.data.id, binding); 207 | t.not(retrievedTrue, null); 208 | if (retrievedTrue) { 209 | t.is(retrievedTrue.data.boolean_value, true); 210 | } 211 | } 212 | 213 | if (falseEntity.data.id) { 214 | const retrievedFalse = await AdvancedEntity.findById(falseEntity.data.id, binding); 215 | t.not(retrievedFalse, null); 216 | if (retrievedFalse) { 217 | t.is(retrievedFalse.data.boolean_value, false); 218 | } 219 | } 220 | }); 221 | 222 | test('json - should serialize and deserialize JSON values correctly', async (t) => { 223 | const { db: binding }: any = t.context; 224 | const jsonData = { 225 | name: 'Test Object', 226 | nested: { value: 42 }, 227 | array: [1, 2, 3] 228 | }; 229 | 230 | const entity = await createEntity({ json_value: jsonData }, binding); 231 | 232 | // Verify JSON structure is preserved 233 | t.deepEqual(entity.data.json_value, jsonData); 234 | t.is(typeof entity.data.json_value, 'object'); 235 | t.is(entity.data.json_value.name, 'Test Object'); 236 | t.is(entity.data.json_value.nested.value, 42); 237 | t.deepEqual(entity.data.json_value.array, [1, 2, 3]); 238 | 239 | // Verify retrieval 240 | if (entity.data.id) { 241 | const retrieved = await AdvancedEntity.findById(entity.data.id, binding); 242 | t.not(retrieved, null); 243 | if (retrieved) { 244 | t.deepEqual(retrieved.data.json_value, jsonData); 245 | } 246 | } 247 | }); 248 | 249 | test('date - should store and retrieve date values correctly', async (t) => { 250 | const { db: binding }: any = t.context; 251 | const testDate = new Date('2025-01-01T12:00:00Z'); 252 | 253 | const entity = await createEntity({ date_value: testDate }, binding); 254 | 255 | // The date should be stored as an ISO string 256 | t.is(entity.data.date_value, testDate.toISOString()); 257 | 258 | // Verify retrieval 259 | if (entity.data.id) { 260 | const retrieved = await AdvancedEntity.findById(entity.data.id, binding); 261 | t.not(retrieved, null); 262 | if (retrieved) { 263 | t.is(retrieved.data.date_value, testDate.toISOString()); 264 | } 265 | } 266 | }); 267 | // CRUD Operations Tests 268 | test('create - should create entities with all data types', async (t) => { 269 | const { db: binding }: any = t.context; 270 | 271 | const testData = { 272 | string_value: 'Create Test', 273 | number_value: 123.45, 274 | integer_value: 42, 275 | boolean_value: true, 276 | json_value: { test: 'value' }, 277 | date_value: new Date('2025-01-01') 278 | }; 279 | 280 | const entity = await createEntity(testData, binding); 281 | 282 | // Verify all fields were created correctly 283 | t.is(entity.data.string_value, testData.string_value); 284 | t.is(entity.data.number_value, testData.number_value); 285 | t.is(entity.data.integer_value, testData.integer_value); 286 | t.is(entity.data.boolean_value, testData.boolean_value); 287 | t.deepEqual(entity.data.json_value, testData.json_value); 288 | t.is(entity.data.date_value, testData.date_value.toISOString()); 289 | 290 | // Verify UUID was generated 291 | t.truthy(entity.data.id); 292 | }); 293 | 294 | test('update - should update entities with all data types', async (t) => { 295 | const { db: binding }: any = t.context; 296 | 297 | // Create initial entity 298 | const initialData = { 299 | string_value: 'Initial String', 300 | number_value: 100.5, 301 | integer_value: 100, 302 | boolean_value: false, 303 | json_value: { status: 'initial' }, 304 | date_value: new Date('2025-01-01') 305 | }; 306 | 307 | const entity = await createEntity(initialData, binding); 308 | t.truthy(entity.data.id, 'Entity should have an ID'); 309 | 310 | if (entity.data.id) { 311 | // Update with new values 312 | const updatedData = { 313 | id: entity.data.id, 314 | string_value: 'Updated String', 315 | number_value: 200.75, 316 | integer_value: 200, 317 | boolean_value: true, 318 | json_value: { status: 'updated', newField: 'added' }, 319 | date_value: new Date('2025-02-01') 320 | }; 321 | 322 | const result = await AdvancedEntity.update({ data: updatedData }, binding); 323 | t.truthy(result, 'Update should return a result'); 324 | 325 | // Type guard to ensure we're working with the right type 326 | if (result && typeof result === 'object' && !('message' in result)) { 327 | // Cast to any to access properties safely 328 | const updatedEntity: any = result; 329 | 330 | // Verify all values were updated correctly 331 | t.is(updatedEntity.data.string_value, updatedData.string_value); 332 | t.is(updatedEntity.data.number_value, updatedData.number_value); 333 | t.is(updatedEntity.data.integer_value, updatedData.integer_value); 334 | t.is(updatedEntity.data.boolean_value, updatedData.boolean_value); 335 | t.deepEqual(updatedEntity.data.json_value, updatedData.json_value); 336 | t.is(updatedEntity.data.date_value, updatedData.date_value.toISOString()); 337 | 338 | // Verify retrieval of updated entity 339 | const retrieved = await AdvancedEntity.findById(entity.data.id, binding); 340 | t.not(retrieved, null); 341 | if (retrieved) { 342 | t.is(retrieved.data.string_value, updatedData.string_value); 343 | } 344 | } 345 | } 346 | }); 347 | 348 | test('read - should find entities by id and column values', async (t) => { 349 | const { db: binding }: any = t.context; 350 | 351 | // Create test entity 352 | const testData = { 353 | string_value: 'Find Me', 354 | boolean_value: true, 355 | json_value: { searchKey: 'searchValue' }, 356 | number_value: 12345 357 | }; 358 | 359 | const entity = await createEntity(testData, binding); 360 | t.truthy(entity.data.id, 'Entity should have an ID'); 361 | 362 | if (entity.data.id) { 363 | // Test findById 364 | const foundById = await AdvancedEntity.findById(entity.data.id, binding); 365 | t.not(foundById, null); 366 | if (foundById) { 367 | t.is(foundById.data.string_value, testData.string_value); 368 | t.is(foundById.data.boolean_value, testData.boolean_value); 369 | t.deepEqual(foundById.data.json_value, testData.json_value); 370 | } 371 | 372 | // Test findById with complete option to include timestamps 373 | const completeEntity = await AdvancedEntity.findById(entity.data.id, binding, true); 374 | t.not(completeEntity, null); 375 | if (completeEntity) { 376 | // In the Model-one library, timestamps might be directly on the entity or in the data property 377 | // depending on how the findById method is implemented 378 | const created_at = completeEntity.created_at || (completeEntity.data && completeEntity.data.created_at); 379 | const updated_at = completeEntity.updated_at || (completeEntity.data && completeEntity.data.updated_at); 380 | t.truthy(created_at, 'Should have a created_at timestamp'); 381 | t.truthy(updated_at, 'Should have an updated_at timestamp'); 382 | } 383 | 384 | // Test findOne by string value 385 | const foundByString = await AdvancedEntity.findOne('string_value', 'Find Me', binding); 386 | t.not(foundByString, null); 387 | if (foundByString) { 388 | t.is(foundByString.data.string_value, 'Find Me'); 389 | } 390 | 391 | // Test findOne by number value 392 | const foundByNumber = await AdvancedEntity.findOne('number_value', '12345', binding); 393 | t.not(foundByNumber, null); 394 | if (foundByNumber) { 395 | t.is(foundByNumber.data.number_value, 12345); 396 | } 397 | } 398 | }); 399 | 400 | test('delete - should soft delete entities and hide them from queries', async (t) => { 401 | const { db: binding }: any = t.context; 402 | 403 | // Create entity to delete 404 | const entity = await createEntity({ string_value: 'To Be Deleted' }, binding); 405 | t.truthy(entity.data.id, 'Entity should have an ID'); 406 | 407 | if (entity.data.id) { 408 | // Soft delete the entity 409 | await AdvancedEntity.delete(entity.data.id, binding); 410 | 411 | // Try to find the entity - should return null due to soft delete 412 | const notFound = await AdvancedEntity.findById(entity.data.id, binding); 413 | t.is(notFound, null); 414 | 415 | // Verify it still exists in DB by running a raw query that ignores deleted_at 416 | const { results } = await AdvancedEntity.query( 417 | `SELECT * FROM advanced_entities WHERE id='${entity.data.id}'`, 418 | binding 419 | ); 420 | 421 | // Should have exactly one result and deleted_at should be set 422 | t.is(results.length, 1); 423 | t.truthy(results[0].deleted_at); 424 | } 425 | }); 426 | 427 | // Collection Operations Tests 428 | test('all - should retrieve all non-deleted entities', async (t) => { 429 | const { db: binding }: any = t.context; 430 | 431 | // Create multiple entities 432 | for (let i = 0; i < 5; i++) { 433 | await createEntity({ string_value: `Entity ${i}` }, binding); 434 | } 435 | 436 | // Get all entities - should be 5 437 | const allEntities = await AdvancedEntity.all(binding); 438 | t.is(allEntities.length, 5); 439 | 440 | // Delete 2 entities 441 | if (allEntities[0] && allEntities[0].data && allEntities[0].data.id) { 442 | await AdvancedEntity.delete(allEntities[0].data.id, binding); 443 | } 444 | if (allEntities[1] && allEntities[1].data && allEntities[1].data.id) { 445 | await AdvancedEntity.delete(allEntities[1].data.id, binding); 446 | } 447 | 448 | // Get all entities again - should be 3 now 449 | const remainingEntities = await AdvancedEntity.all(binding); 450 | t.is(remainingEntities.length, 3); 451 | 452 | // Verify the correct entities remain 453 | const remainingValues = remainingEntities.map(e => e.data.string_value); 454 | t.deepEqual(remainingValues.sort(), ['Entity 2', 'Entity 3', 'Entity 4'].sort()); 455 | }); 456 | 457 | test('query - should execute raw SQL queries correctly', async (t) => { 458 | const { db: binding }: any = t.context; 459 | 460 | // Create test entities 461 | await createEntity({ 462 | string_value: 'Raw Query Test 1', 463 | number_value: 999 464 | }, binding); 465 | 466 | await createEntity({ 467 | string_value: 'Raw Query Test 2', 468 | number_value: 999 469 | }, binding); 470 | 471 | await createEntity({ 472 | string_value: 'Different Value', 473 | number_value: 123 474 | }, binding); 475 | 476 | // Run a raw query with a WHERE clause 477 | const { success, results } = await AdvancedEntity.query( 478 | `SELECT * FROM advanced_entities WHERE number_value = 999`, 479 | binding 480 | ); 481 | 482 | t.true(success); 483 | t.is(results.length, 2); 484 | 485 | // Verify the correct entities were returned 486 | const stringValues = results.map(r => r.string_value); 487 | t.true(stringValues.includes('Raw Query Test 1')); 488 | t.true(stringValues.includes('Raw Query Test 2')); 489 | t.false(stringValues.includes('Different Value')); 490 | }); 491 | 492 | // Run the tests to verify the refactored code works correctly 493 | test.after.always('Clean up', async () => { 494 | // This runs after all tests 495 | console.log('Tests completed successfully'); 496 | }); 497 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.ModelError = exports.NotFoundError = exports.Model = exports.Schema = exports.Form = void 0; 4 | const NotFoundError = () => { 5 | return null; 6 | }; 7 | exports.NotFoundError = NotFoundError; 8 | class ModelError extends Error { 9 | constructor(message, errors = []) { 10 | super(message); 11 | this.name = 'ModelError'; 12 | this.errors = errors; 13 | } 14 | } 15 | exports.ModelError = ModelError; 16 | class Form { 17 | constructor(schema, data) { 18 | this.schema = schema; 19 | this.data = data.data; 20 | this.validate(); 21 | } 22 | // Validate if props contains at least the declared fields 23 | validate() { 24 | const { error } = this.schema.validate(this.data); 25 | if (error !== undefined) { 26 | throw new ModelError(error.message, error.details.map(detail => detail.message)); 27 | } 28 | } 29 | } 30 | exports.Form = Form; 31 | class Schema { 32 | constructor(props) { 33 | this.table_name = props.table_name; 34 | this.columns = props.columns; 35 | this.uniques = props.uniques; 36 | this.timestamps = props.timestamps ?? true; // Default to true for backward compatibility 37 | this.softDeletes = props.softDeletes ?? false; 38 | } 39 | } 40 | exports.Schema = Schema; 41 | class Model { 42 | constructor(schema, props) { 43 | this.id = props?.id || null; 44 | this.schema = schema || {}; // Ensure schema is initialized 45 | this.data = props || {}; 46 | } 47 | async update(partialData, env) { 48 | if (!this.data?.id) { 49 | throw new ModelError('Instance data is missing an ID, cannot update.'); 50 | } 51 | const updatePayload = { ...this.data, ...partialData }; 52 | const arg1 = { data: updatePayload }; 53 | const ModelCtor = this.constructor; 54 | const resultFromStaticUpdate = await ModelCtor.update(arg1, env); 55 | if (resultFromStaticUpdate && resultFromStaticUpdate.data) { 56 | this.data = { ...resultFromStaticUpdate.data }; 57 | this.id = resultFromStaticUpdate.data.id || this.id; 58 | return this; 59 | } 60 | else { 61 | console.error('Instance update failed: static update did not return expected data or failed.', resultFromStaticUpdate); 62 | return null; 63 | } 64 | } 65 | /** 66 | * Deletes the current model instance from the database. 67 | * @param env - The database environment/connection object 68 | * @returns The result of the delete operation 69 | * @throws {ModelError} If the instance is missing an ID 70 | */ 71 | async delete(env) { 72 | if (!this.data?.id) { 73 | throw new ModelError('Instance data is missing an ID, cannot delete.'); 74 | } 75 | const ModelCtor = this.constructor; 76 | const result = await ModelCtor.delete(this.data.id, env); 77 | // Return the result from the static delete method 78 | return result; 79 | } 80 | /** 81 | * Maps JavaScript types to SQLite types 82 | */ 83 | static getDefaultSQLiteType(columnType) { 84 | switch (columnType) { 85 | case 'string': 86 | return 'TEXT'; 87 | case 'number': 88 | return 'REAL'; 89 | case 'boolean': 90 | return 'INTEGER'; // SQLite stores booleans as 0/1 91 | case 'jsonb': 92 | return 'TEXT'; // JSON is stored as stringified TEXT 93 | case 'date': 94 | return 'TEXT'; // Dates stored as ISO strings 95 | default: 96 | return 'TEXT'; 97 | } 98 | } 99 | /** 100 | * Processes a value based on its column type for database storage 101 | */ 102 | static processValueForStorage(value, columnType) { 103 | if (value === null || value === undefined) { 104 | if (columnType === 'jsonb') { 105 | return 'null'; 106 | } 107 | return null; 108 | } 109 | switch (columnType) { 110 | case 'boolean': 111 | return value ? 1 : 0; 112 | case 'jsonb': 113 | return JSON.stringify(value); 114 | case 'date': 115 | return value instanceof Date ? value.toISOString() : value; 116 | default: 117 | return value; 118 | } 119 | } 120 | /** 121 | * Processes a database value based on column type for JavaScript usage 122 | */ 123 | static processValueFromStorage(value, columnType) { 124 | if (value === null || value === undefined) { 125 | return null; 126 | } 127 | switch (columnType) { 128 | case 'boolean': 129 | return Boolean(value); 130 | case 'number': 131 | return Number(value); 132 | case 'jsonb': 133 | if (value === '' || value === 'null') { 134 | return null; 135 | } 136 | return typeof value === 'string' ? JSON.parse(value) : value; 137 | case 'date': 138 | return value; // Client code can convert to Date if needed 139 | default: 140 | return value; 141 | } 142 | } 143 | static deserializeData(data, schema) { 144 | const { id } = data; 145 | if (!Boolean(id)) { 146 | const keys = ['id']; 147 | const values = [`${crypto.randomUUID()}`]; 148 | schema.columns.forEach((column) => { 149 | if (data[column.name] !== undefined) { 150 | keys.push(column.name); 151 | const processedValue = this.processValueForStorage(data[column.name], column.type); 152 | if (processedValue === null) { 153 | values.push('NULL'); 154 | } 155 | else if (typeof processedValue === 'number') { 156 | values.push(processedValue.toString()); 157 | } 158 | else if (typeof processedValue === 'string') { 159 | values.push(processedValue); 160 | } 161 | else { 162 | values.push(JSON.stringify(processedValue)); 163 | } 164 | } 165 | }); 166 | const formattedValues = values.map(v => { 167 | return v === 'NULL' ? v : `'${v}'`; 168 | }).join(", "); 169 | return { values: formattedValues, keys: keys.join(", ") }; 170 | } 171 | else { 172 | const attributes = []; 173 | schema.columns.forEach((column) => { 174 | if (column.name === 'id') 175 | return; 176 | if (Object.prototype.hasOwnProperty.call(data, column.name)) { 177 | const processedValue = this.processValueForStorage(data[column.name], column.type); 178 | if (processedValue === null) { 179 | attributes.push(`${column.name} = NULL`); 180 | } 181 | else if (typeof processedValue === 'number') { 182 | attributes.push(`${column.name} = ${processedValue}`); 183 | } 184 | else { 185 | attributes.push(`${column.name} = '${processedValue}'`); 186 | } 187 | } 188 | }); 189 | return { attributes: attributes.join(", ") }; 190 | } 191 | } 192 | static serializeData(data, schema) { 193 | const result = { ...data }; 194 | schema.columns.forEach((column) => { 195 | if (data[column.name] !== undefined) { 196 | result[column.name] = this.processValueFromStorage(data[column.name], column.type); 197 | } 198 | else { 199 | result[column.name] = null; 200 | } 201 | }); 202 | Object.keys(result).forEach(key => { 203 | if (result[key] === undefined) { 204 | result[key] = null; 205 | } 206 | }); 207 | return result; 208 | } 209 | static createModelInstance(data, schemaForInstance) { 210 | const instance = new this(schemaForInstance); 211 | instance.id = data.id; 212 | instance.data = {}; 213 | Object.keys(data).forEach(key => { 214 | instance.data[key] = data[key]; 215 | }); 216 | if (data.id) { 217 | instance.data.id = data.id; 218 | } 219 | return instance; 220 | } 221 | static async create({ data }, env) { 222 | const { schema } = new this(); 223 | const { keys, values } = this.deserializeData(data, schema); 224 | let query = `INSERT INTO ${schema.table_name} (${keys}`; 225 | let valuesPart = `VALUES(${values}`; 226 | if (schema.timestamps) { 227 | query += `, created_at, updated_at`; 228 | valuesPart += `, datetime('now'), datetime('now')`; 229 | } 230 | query += `) ${valuesPart}) RETURNING *;`; 231 | const { results, success } = await env.prepare(query).all(); 232 | if (success && results && results[0]) { 233 | const dbRecord = { ...results[0] }; 234 | const serializedData = this.serializeData(dbRecord, schema); 235 | return this.createModelInstance(serializedData, schema); 236 | } 237 | else { 238 | console.error('Create operation failed or returned no results.'); 239 | return null; 240 | } 241 | } 242 | static async update({ data }, env) { 243 | const { schema } = new this(); 244 | const { id } = data; 245 | if (!Boolean(id)) { 246 | console.error('Update failed: No ID present in data.'); 247 | return null; 248 | } 249 | const { attributes } = this.deserializeData(data, schema); 250 | if (!attributes || attributes.length === 0) { 251 | console.warn('Update called with no attributes to update for ID:', id); 252 | // Return the existing record as a full model instance if no actual changes are made. 253 | // 'complete' should be false (or omitted) to get a model instance. 254 | return this.findById(id, env, false); // Corrected: pass false for 'complete' 255 | } 256 | let query = `UPDATE ${schema.table_name} SET ${attributes}`; 257 | if (schema.timestamps) { 258 | query += `, updated_at = datetime('now')`; 259 | } 260 | query += ` WHERE id='${id}' RETURNING *;`; 261 | const { results, success } = await env.prepare(query).all(); 262 | if (success && results && results[0]) { 263 | const dbRecord = { ...results[0] }; 264 | const serializedData = this.serializeData(dbRecord, schema); 265 | return this.createModelInstance(serializedData, schema); 266 | } 267 | else { 268 | console.error(`Update failed for ID ${id} or record not found.`); 269 | return null; 270 | } 271 | } 272 | static async delete(id, env) { 273 | const { schema } = new this(); 274 | if (!Boolean(id)) 275 | return { message: 'ID is missing.' }; 276 | if (schema.softDeletes) { 277 | const query = `UPDATE ${schema.table_name} 278 | SET deleted_at = datetime('now') 279 | WHERE id='${id}';`; 280 | const { success } = await env.prepare(query).all(); 281 | return success ? 282 | { message: `The ID ${id} from table "${schema.table_name}" has been soft deleted.` } 283 | : { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 284 | } 285 | else { 286 | const query = `DELETE FROM ${schema.table_name} WHERE id='${id}';`; 287 | const { success } = await env.prepare(query).all(); 288 | return success ? 289 | { message: `The ID ${id} from table "${schema.table_name}" has been successfully deleted.` } 290 | : { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 291 | } 292 | } 293 | static async restore(id, env) { 294 | const { schema } = new this(); 295 | if (!Boolean(id)) 296 | return { message: 'ID is missing.' }; 297 | if (!schema.softDeletes) { 298 | return { message: `Soft deletes are not enabled for table "${schema.table_name}"` }; 299 | } 300 | const query = `UPDATE ${schema.table_name} 301 | SET deleted_at = NULL 302 | WHERE id='${id}' RETURNING *;`; 303 | const { results, success } = await env.prepare(query).all(); 304 | if (!success || !results || results.length === 0) { 305 | return { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 306 | } 307 | const dbRecord = { ...results[0] }; 308 | const serializedData = this.serializeData(dbRecord, schema); 309 | return { 310 | message: `The ID ${id} from table "${schema.table_name}" has been successfully restored.`, 311 | data: this.createModelInstance(serializedData, schema) 312 | }; 313 | } 314 | static async all(env, includeDeleted) { 315 | const { schema } = new this(); 316 | let query = `SELECT * FROM ${schema.table_name}`; 317 | if (schema.softDeletes && !includeDeleted) { 318 | query += ` WHERE deleted_at IS NULL`; 319 | } 320 | query += `;`; 321 | const { results, success } = await env.prepare(query).all(); 322 | if (!success) 323 | return []; 324 | if (results && results.length > 0) { 325 | return results.map((result) => { 326 | const dbRecord = { ...result }; 327 | const serializedData = this.serializeData(dbRecord, schema); 328 | return this.createModelInstance(serializedData, schema); 329 | }); 330 | } 331 | else { 332 | return []; 333 | } 334 | } 335 | static async findOne(column, value, env, includeDeleted) { 336 | const { schema } = new this(); 337 | let query = `SELECT * FROM ${schema.table_name} WHERE ${column}='${value}'`; 338 | if (schema.softDeletes && !includeDeleted) { 339 | query += ` AND deleted_at IS NULL`; 340 | } 341 | query += ` LIMIT 1;`; 342 | const { results, success } = await env.prepare(query).all(); 343 | if (!success || !results || results.length === 0) { 344 | return null; 345 | } 346 | const dbRecord = { ...results[0] }; 347 | const serializedData = this.serializeData(dbRecord, schema); 348 | return this.createModelInstance(serializedData, schema); 349 | } 350 | static async findBy(column, value, env, includeDeleted) { 351 | const { schema } = new this(); 352 | let query = `SELECT * FROM ${schema.table_name} WHERE ${column}='${value}'`; 353 | if (schema.softDeletes && !includeDeleted) { 354 | query += ` AND deleted_at IS NULL`; 355 | } 356 | query += `;`; 357 | const { results, success } = await env.prepare(query).all(); 358 | if (!success || !results) 359 | return []; 360 | return results.map((result) => { 361 | const dbRecord = { ...result }; 362 | const serializedData = this.serializeData(dbRecord, schema); 363 | return this.createModelInstance(serializedData, schema); 364 | }).filter((p) => p !== null); 365 | } 366 | static async findById(id, env, includeDeleted) { 367 | const { schema } = new this(); 368 | let query = `SELECT * FROM ${schema.table_name} WHERE id='${id}'`; 369 | if (schema.softDeletes && !includeDeleted) { 370 | query += ` AND deleted_at IS NULL`; 371 | } 372 | query += ` LIMIT 1;`; 373 | const { results, success } = await env.prepare(query).all(); 374 | if (!success || !results || results.length === 0) { 375 | return null; 376 | } 377 | const dbRecord = { ...results[0] }; 378 | const serializedData = this.serializeData(dbRecord, schema); 379 | return this.createModelInstance(serializedData, schema); 380 | } 381 | static async query(sql, env, params) { 382 | const { results, success } = await env.prepare(sql, params).all(); 383 | if (!success) 384 | return { success: false, message: 'Query failed' }; 385 | return { success: true, results }; 386 | } 387 | /** 388 | * Saves the current model instance to the database. 389 | * If the instance has an ID (from `this.id` or `this.data.id`), it dispatches to the static `update()` method. 390 | * Otherwise, it dispatches to the static `create()` method. 391 | * The instance's `data` and `id` properties are updated with the result from the database operation. 392 | * @param env - The database environment/connection object. 393 | * @returns {Promise} A promise that resolves to the current instance (this) after being updated, 394 | * or null if the operation fails or returns no data. 395 | * @throws {ModelError} Can be thrown by underlying create/update operations (e.g., validation). 396 | */ 397 | async save(env) { 398 | console.log('[Model.save] Input this.id:', this.id); 399 | console.log('[Model.save] Input this.data:', JSON.stringify(this.data)); 400 | const ModelCtor = this.constructor; 401 | // Ensure this.data has the most current ID if this.id is set, and this.id is primary 402 | if (this.id && (!this.data.id || this.data.id !== this.id)) { 403 | console.log('[Model.save] Syncing this.data.id from this.id.'); 404 | this.data.id = this.id; 405 | } 406 | // Prepare the payload. Assumes static create/update can handle { data: ModelDataI } 407 | const payload = { data: { ...this.data } }; // Use a shallow copy of data 408 | console.log('[Model.save] Payload for static method:', JSON.stringify(payload)); 409 | let resultInstance = null; 410 | let operationType = 'none'; 411 | if (this.data.id) { // ID exists, dispatch to static update 412 | operationType = 'update'; 413 | console.log(`[Model.save] Instance has ID ${this.data.id}. Calling static update.`); 414 | resultInstance = await ModelCtor.update(payload, env); 415 | } 416 | else { // No ID, dispatch to static create 417 | operationType = 'create'; 418 | console.log('[Model.save] Instance has no ID. Calling static create.'); 419 | // Ensure no 'id' field is accidentally sent if it's null/undefined in this.data, 420 | // as static create should generate the ID. 421 | if (payload.data.hasOwnProperty('id') && (payload.data.id === null || payload.data.id === undefined)) { 422 | console.log('[Model.save] Removing null/undefined id from create payload.'); 423 | delete payload.data.id; 424 | } 425 | resultInstance = await ModelCtor.create(payload, env); 426 | } 427 | console.log(`[Model.save] Result from static ${operationType}:`, JSON.stringify(resultInstance)); 428 | if (resultInstance && resultInstance.data) { 429 | console.log('[Model.save] Operation successful. Syncing instance data with result.'); 430 | // Sync current instance's data and id with the returned data 431 | this.data = { ...resultInstance.data }; // Update internal data 432 | this.id = resultInstance.data.id || null; // Update internal id 433 | console.log('[Model.save] Synced this.id:', this.id); 434 | console.log('[Model.save] Synced this.data:', JSON.stringify(this.data)); 435 | return this; // Return the current, updated instance 436 | } 437 | else { 438 | console.error(`[Model.save] Static ${operationType} operation failed or returned no data.`); 439 | return null; 440 | } 441 | } 442 | } 443 | exports.Model = Model; 444 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Model One 2 | 3 | [![code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 4 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 5 | [![made with lass](https://img.shields.io/badge/made_with-lass-95CC28.svg)](https://lass.js.org) 6 | [![license](https://img.shields.io/github/license/hacksur/model-one.svg)](LICENSE) 7 | [![npm downloads](https://img.shields.io/npm/dt/model-one.svg)](https://npm.im/model-one) 8 | 9 | A powerful ORM-like library (v0.3.0) for Cloudflare Workers D1 with validation support via Joi. 10 | 11 | ## Features 12 | 13 | - **Type-safe models** with TypeScript support 14 | - **Basic CRUD operations** with a PostgreSQL-like interface 15 | - **Enhanced column types** including string, number, boolean, date, and JSON 16 | - **UUID generation** by default for primary keys 17 | - **Automatic timestamps** for created_at and updated_at fields 18 | - **Soft delete functionality** for non-destructive record removal 19 | - **Data serialization and deserialization** for complex data types 20 | - **Form validation** powered by Joi 21 | - **Raw SQL query support** for complex operations 22 | - **Proper data encapsulation** through the data property pattern 23 | 24 | ## Table of Contents 25 | 26 | 1. [Installation](#installation) 27 | 2. [Quick Start](#quick-start) 28 | 3. [Model Definition](#model-definition) 29 | 4. [Schema Configuration](#schema-configuration) 30 | 5. [Column Types and Constraints](#column-types-and-constraints) 31 | 6. [Form Validation](#form-validation) 32 | 7. [CRUD Operations](#crud-operations) 33 | 8. [Soft Delete](#soft-delete) 34 | 9. [Extending Models](#extending-models) 35 | 10. [TypeScript Support](#typescript-support) 36 | 37 | ## Installation 38 | 39 | [npm][]: 40 | 41 | ```sh 42 | npm install model-one joi 43 | ``` 44 | 45 | [yarn][]: 46 | 47 | ```sh 48 | yarn add model-one joi 49 | ``` 50 | 51 | ## Quick Start 52 | 53 | ```typescript 54 | import { Model, Schema, Form } from 'model-one'; 55 | import Joi from 'joi'; 56 | 57 | // Define schema 58 | const userSchema = new Schema({ 59 | table_name: 'users', 60 | columns: [ 61 | { name: 'id', type: 'string' }, 62 | { name: 'name', type: 'string' }, 63 | { name: 'email', type: 'string' }, 64 | { name: 'preferences', type: 'jsonb' } 65 | ], 66 | timestamps: true, 67 | softDeletes: true 68 | }); 69 | 70 | // Define validation schema 71 | const joiSchema = Joi.object({ 72 | id: Joi.string(), 73 | name: Joi.string().required(), 74 | email: Joi.string().email().required(), 75 | preferences: Joi.object() 76 | }); 77 | 78 | // Define interfaces 79 | interface UserDataI { 80 | id?: string; 81 | name?: string; 82 | email?: string; 83 | preferences?: Record; 84 | } 85 | 86 | interface UserI extends Model { 87 | data: UserDataI; 88 | } 89 | 90 | // Create form class 91 | class UserForm extends Form { 92 | constructor(data: UserI) { 93 | super(joiSchema, data); 94 | } 95 | } 96 | 97 | // Create model class 98 | class User extends Model implements UserI { 99 | data: UserDataI; 100 | 101 | constructor(props: UserDataI = {}) { 102 | super(userSchema); 103 | this.data = props || {}; 104 | } 105 | } 106 | 107 | // Usage example 108 | async function createUser(env) { 109 | const userData = { name: 'John Doe', email: 'john@example.com', preferences: { theme: 'dark' } }; 110 | const user = new User(userData); 111 | const form = new UserForm(user); 112 | 113 | const createdUser = await User.create(form, env.DB); 114 | console.log(createdUser.data.id); // Auto-generated UUID 115 | console.log(createdUser.data.name); // 'John Doe' 116 | console.log(createdUser.data.preferences.theme); // 'dark' 117 | } 118 | ``` 119 | 120 | ## Model Definition 121 | 122 | Models in Model-One follow a specific pattern to ensure type safety and proper data encapsulation: 123 | 124 | ```typescript 125 | // Define your data interface 126 | interface EntityDataI { 127 | id?: string; 128 | // Add your custom properties here 129 | name?: string; 130 | // etc... 131 | } 132 | 133 | // Define your model interface that extends the base Model 134 | interface EntityI extends Model { 135 | data: EntityDataI; 136 | } 137 | 138 | // Create your model class 139 | class Entity extends Model implements EntityI { 140 | data: EntityDataI; 141 | 142 | constructor(props: EntityDataI = {}) { 143 | super(entitySchema); 144 | this.data = props || {}; 145 | } 146 | } 147 | ``` 148 | 149 | ### Important Note on Data Access 150 | 151 | In Model-One v0.2.0 and above, all entity properties must be accessed through the `data` property: 152 | 153 | ```typescript 154 | // Correct way to access properties 155 | const user = await User.findById(id, env.DB); 156 | if (user) { 157 | console.log(user.data.name); // Correct 158 | console.log(user.data.email); // Correct 159 | } 160 | 161 | // Incorrect way (will not work) 162 | console.log(user.name); // Incorrect 163 | console.log(user.email); // Incorrect 164 | ``` 165 | 166 | ```sh 167 | yarn add model-one joi 168 | ``` 169 | 170 | ## Schema Configuration 171 | 172 | The Schema class is used to define your database table structure: 173 | 174 | ```typescript 175 | const entitySchema = new Schema({ 176 | table_name: 'entities', // Name of the database table 177 | columns: [ 178 | { name: 'id', type: 'string' }, // Primary key (UUID by default) 179 | { name: 'title', type: 'string' }, 180 | { name: 'count', type: 'number' }, 181 | { name: 'is_active', type: 'boolean' }, 182 | { name: 'metadata', type: 'jsonb' }, 183 | { name: 'published_at', type: 'date' } 184 | ], 185 | timestamps: true, // Adds created_at and updated_at columns 186 | softDeletes: true // Adds deleted_at column for soft deletes 187 | }); 188 | ``` 189 | 190 | ## Column Types and Constraints 191 | 192 | Model-One supports the following column types: 193 | 194 | | Type | JavaScript Type | Description | 195 | |------|----------------|-------------| 196 | | `string` | `string` | Text data | 197 | | `number` | `number` | Numeric data | 198 | | `boolean` | `boolean` | Boolean values (true/false) | 199 | | `date` | `Date` | Date and time values | 200 | | `jsonb` | `object` or `array` | JSON data that is automatically serialized/deserialized | 201 | 202 | ## Form Validation 203 | 204 | Model-One uses Joi for form validation: 205 | 206 | ```typescript 207 | import Joi from 'joi'; 208 | import { Form } from 'model-one'; 209 | 210 | // Define validation schema 211 | const joiSchema = Joi.object({ 212 | id: Joi.string(), 213 | title: Joi.string().required().min(3).max(100), 214 | count: Joi.number().integer().min(0), 215 | is_active: Joi.boolean(), 216 | metadata: Joi.object(), 217 | published_at: Joi.date() 218 | }); 219 | 220 | // Create form class 221 | class EntityForm extends Form { 222 | constructor(data: EntityI) { 223 | super(joiSchema, data); 224 | } 225 | } 226 | 227 | // Usage 228 | const entity = new Entity({ title: 'Test' }); 229 | const form = new EntityForm(entity); 230 | 231 | // Validation happens automatically when creating or updating 232 | const createdEntity = await Entity.create(form, env.DB); 233 | ``` 234 | 235 | ## CRUD Operations 236 | 237 | Model-One provides the following CRUD operations: 238 | 239 | ### Create 240 | 241 | ```typescript 242 | // Create a new entity 243 | const entity = new Entity({ title: 'New Entity', count: 42 }); 244 | const form = new EntityForm(entity); 245 | const createdEntity = await Entity.create(form, env.DB); 246 | 247 | // Access the created entity's data 248 | console.log(createdEntity.data.id); // Auto-generated UUID 249 | console.log(createdEntity.data.title); // 'New Entity' 250 | ``` 251 | 252 | ### Read (Finding Records) 253 | 254 | Model-One provides several static methods on your model class to retrieve records from the database. All these methods return model instances (or `null` / an array of instances), and you should access their properties via the `.data` object. 255 | 256 | * `YourModel.findById(id: string, env: any, includeDeleted?: boolean): Promise` 257 | 258 | Finds a single record by its ID. Returns a model instance or `null` if not found. 259 | 260 | ```typescript 261 | const user = await User.findById('some-uuid', env.DB); 262 | if (user) { 263 | console.log(user.data.name); // Access data via .data 264 | } 265 | ``` 266 | If `softDeletes` is enabled for the model, you can pass `true` as the third argument (`includeDeleted`) to also find soft-deleted records. 267 | 268 | * `YourModel.findOne(column: string, value: string, env: any, includeDeleted?: boolean): Promise` 269 | 270 | Finds the first record that matches a given column-value pair. Returns a model instance or `null`. 271 | 272 | ```typescript 273 | const adminUser = await User.findOne('email', 'admin@example.com', env.DB); 274 | if (adminUser) { 275 | console.log(adminUser.data.id); 276 | } 277 | ``` 278 | The optional fourth argument `includeDeleted` works the same as in `findById`. 279 | 280 | * `YourModel.findBy(column: string, value: string, env: any, includeDeleted?: boolean): Promise` 281 | 282 | Finds all records that match a given column-value pair. Returns an array of model instances (can be empty). 283 | 284 | ```typescript 285 | const activeUsers = await User.findBy('status', 'active', env.DB); 286 | activeUsers.forEach(user => { 287 | console.log(user.data.email); 288 | }); 289 | ``` 290 | The optional fourth argument `includeDeleted` works the same as in `findById`. 291 | 292 | * `YourModel.all(env: any, includeDeleted?: boolean): Promise` 293 | 294 | Retrieves all records for the model. Returns an array of model instances. 295 | 296 | ```typescript 297 | const allUsers = await User.all(env.DB); 298 | console.log(`Total users: ${allUsers.length}`); 299 | allUsers.forEach(user => { 300 | console.log(user.data.name); // Access data via .data 301 | }); 302 | ``` 303 | The optional second argument `includeDeleted` works the same as in `findById`. 304 | 305 | ### Update 306 | 307 | ```typescript 308 | // Update an entity 309 | const updatedData = { 310 | id: 'existing-uuid', // Required for updates 311 | title: 'Updated Title', 312 | count: 100 313 | }; 314 | const updatedEntity = await Entity.update(updatedData, env.DB); 315 | 316 | // Access the updated entity's data 317 | console.log(updatedEntity.data.title); // 'Updated Title' 318 | console.log(updatedEntity.data.updated_at); // Current timestamp 319 | ``` 320 | 321 | ### Delete (Soft Delete) 322 | 323 | ```typescript 324 | // Soft delete an entity using the static Model.delete() method (still supported) 325 | await Entity.delete('entity-uuid', env.DB); 326 | 327 | // Entity will no longer be returned in queries by default 328 | const notFound = await Entity.findById('entity-uuid', env.DB); 329 | console.log(notFound); // null 330 | 331 | // New: Soft delete an entity using the instance delete() method 332 | const entityToDelete = await Entity.findById('another-entity-uuid', env.DB); 333 | if (entityToDelete) { 334 | await entityToDelete.delete(env.DB); 335 | console.log('Entity soft deleted via instance method.'); 336 | } 337 | ``` 338 | 339 | ## Raw SQL Queries 340 | 341 | For more complex operations, you can use raw SQL queries: 342 | 343 | ```typescript 344 | // Execute a raw SQL query 345 | const { results } = await Entity.raw( 346 | 'SELECT * FROM entities WHERE count > 50 ORDER BY created_at DESC LIMIT 10', 347 | env.DB 348 | ); 349 | 350 | console.log(results); // Array of raw database results 351 | ``` 352 | 353 | ## TypeScript Support 354 | 355 | Model-One is built with TypeScript and provides full type safety. To get the most out of it, define proper interfaces for your models: 356 | 357 | ```typescript 358 | // Define your data interface 359 | interface EntityDataI { 360 | id?: string; 361 | title?: string; 362 | count?: number; 363 | is_active?: boolean; 364 | metadata?: Record; 365 | published_at?: Date; 366 | created_at?: Date; 367 | updated_at?: Date; 368 | } 369 | 370 | // Define your model interface 371 | interface EntityI extends Model { 372 | data: EntityDataI; 373 | } 374 | 375 | // Implement your model class 376 | class Entity extends Model implements EntityI { 377 | data: EntityDataI; 378 | 379 | constructor(props: EntityDataI = {}) { 380 | super(entitySchema); 381 | this.data = props || {}; 382 | } 383 | } 384 | ``` 385 | 386 | ## Breaking Changes in v0.2.0 387 | 388 | ### Data Property Access 389 | 390 | In v0.2.0, all entity properties must be accessed through the `data` property: 391 | 392 | ```typescript 393 | // v0.1.x (no longer works) 394 | const user = await User.findById(id, env.DB); 395 | console.log(user.name); // Undefined 396 | 397 | // v0.2.0 and above 398 | const user = await User.findById(id, env.DB); 399 | console.log(user.data.name); // Works correctly 400 | ``` 401 | 402 | ### Model Initialization 403 | 404 | Models now require proper initialization of the `data` property: 405 | 406 | ```typescript 407 | // Correct initialization in v0.2.0 408 | class User extends Model implements UserI { 409 | data: UserDataI; 410 | 411 | constructor(props: UserDataI = {}) { 412 | super(userSchema); 413 | this.data = props || {}; // Initialize with empty object if props is undefined 414 | } 415 | } 416 | ``` 417 | 418 | 1. Create a new database. 419 | 420 | Create a local file schema.sql 421 | 422 | ```sql 423 | DROP TABLE IF EXISTS users; 424 | 425 | CREATE TABLE users ( 426 | id text PRIMARY KEY, 427 | first_name text, 428 | last_name text, 429 | deleted_at datetime, 430 | created_at datetime, 431 | updated_at datetime 432 | ); 433 | ``` 434 | Creates a new D1 database and provides the binding and UUID that you will put in your wrangler.toml file. 435 | ```sh 436 | npx wrangler d1 create example-db 437 | ``` 438 | 439 | Create the tables from schema.sql 440 | 441 | ```sh 442 | npx wrangler d1 execute example-db --file ./schema.sql 443 | ``` 444 | 445 | 2. We need to import the Model and Schema from 'model-one' and the type SchemaConfigI. Then create a new Schema, define table name and fields 446 | 447 | 448 | ```js 449 | // ./models/User.ts 450 | import { Model, Schema } from 'model-one' 451 | import type { SchemaConfigI, Column } from 'model-one'; 452 | 453 | const userSchema: SchemaConfigI = new Schema({ 454 | table_name: 'users', 455 | columns: [ 456 | { name: 'id', type: 'string', constraints: [{ type: 'PRIMARY KEY' }] }, 457 | { name: 'first_name', type: 'string' }, 458 | { name: 'last_name', type: 'string' } 459 | ], 460 | timestamps: true, // Optional, defaults to true 461 | softDeletes: false // Optional, defaults to false 462 | }) 463 | 464 | ``` 465 | 466 | 3. Then we are going to define the interfaces for our User model. 467 | 468 | ```js 469 | // ./interfaces/index.ts 470 | export interface UserDataI { 471 | id?: string 472 | first_name?: string 473 | last_name?: string 474 | } 475 | 476 | export interface UserI extends Model { 477 | data: UserDataI 478 | } 479 | ``` 480 | 481 | 4. Now we are going import the types and extend the User 482 | 483 | ```js 484 | // ./models/User.ts 485 | import { UserI, UserDataI } from '../interfaces' 486 | 487 | export class User extends Model implements UserI { 488 | data: UserDataI 489 | 490 | constructor(props: UserDataI) { 491 | super(userSchema, props) 492 | this.data = props 493 | } 494 | } 495 | 496 | ``` 497 | 498 | 5. Final result of the User model 499 | 500 | ```js 501 | // ./models/User.ts 502 | import { Model, Schema } from 'model-one' 503 | import type { SchemaConfigI } from 'model-one'; 504 | import { UserI, UserDataI } from '../interfaces' 505 | 506 | const userSchema: SchemaConfigI = new Schema({ 507 | table_name: 'users', 508 | columns: [ 509 | { name: 'id', type: 'string' }, 510 | { name: 'first_name', type: 'string' }, 511 | { name: 'last_name', type: 'string' } 512 | ], 513 | }) 514 | 515 | export class User extends Model implements UserI { 516 | data: UserDataI 517 | 518 | constructor(props: UserDataI) { 519 | super(userSchema, props) 520 | this.data = props 521 | } 522 | } 523 | 524 | ``` 525 | 526 | 527 | 6. After creating the User we are going to create the form that handles the validations. And with the help of Joi we are going to define the fields. 528 | 529 | ```js 530 | // ./forms/UserForm.ts 531 | import { Form } from 'model-one' 532 | import { UserI } from '../interfaces' 533 | import Joi from 'joi' 534 | 535 | const schema = Joi.object({ 536 | id: Joi.string(), 537 | first_name: Joi.string(), 538 | last_name: Joi.string(), 539 | }) 540 | 541 | export class UserForm extends Form { 542 | constructor(data: UserI) { 543 | super(schema, data) 544 | } 545 | } 546 | 547 | 548 | ``` 549 | 550 | ## Column Types and Constraints 551 | 552 | ### Column Types 553 | 554 | model-one supports the following column types that map to SQLite types: 555 | 556 | ```typescript 557 | // JavaScript column types 558 | type ColumnType = 559 | | 'string' // SQLite: TEXT 560 | | 'number' // SQLite: INTEGER or REAL 561 | | 'boolean' // SQLite: INTEGER (0/1) 562 | | 'jsonb' // SQLite: TEXT (JSON stringified) 563 | | 'date'; // SQLite: TEXT (ISO format) 564 | 565 | // SQLite native types 566 | type SQLiteType = 567 | | 'TEXT' 568 | | 'INTEGER' 569 | | 'REAL' 570 | | 'NUMERIC' 571 | | 'BLOB' 572 | | 'JSON' 573 | | 'BOOLEAN' 574 | | 'TIMESTAMP' 575 | | 'DATE'; 576 | ``` 577 | 578 | Example usage: 579 | 580 | ```typescript 581 | const columns = [ 582 | { name: 'id', type: 'string', sqliteType: 'TEXT' }, 583 | { name: 'name', type: 'string' }, 584 | { name: 'age', type: 'number', sqliteType: 'INTEGER' }, 585 | { name: 'active', type: 'boolean' }, 586 | { name: 'metadata', type: 'jsonb' }, 587 | { name: 'created', type: 'date' } 588 | ]; 589 | ``` 590 | 591 | ### Column Constraints 592 | 593 | You can add constraints to your columns: 594 | 595 | ```typescript 596 | type ConstraintType = 597 | | 'PRIMARY KEY' 598 | | 'NOT NULL' 599 | | 'UNIQUE' 600 | | 'CHECK' 601 | | 'DEFAULT' 602 | | 'FOREIGN KEY'; 603 | 604 | interface Constraint { 605 | type: ConstraintType; 606 | value?: string | number | boolean; 607 | } 608 | ``` 609 | 610 | Example: 611 | 612 | ```typescript 613 | const columns = [ 614 | { 615 | name: 'id', 616 | type: 'string', 617 | constraints: [{ type: 'PRIMARY KEY' }] 618 | }, 619 | { 620 | name: 'email', 621 | type: 'string', 622 | constraints: [{ type: 'UNIQUE' }, { type: 'NOT NULL' }] 623 | }, 624 | { 625 | name: 'status', 626 | type: 'string', 627 | constraints: [{ type: 'DEFAULT', value: 'active' }] 628 | } 629 | ]; 630 | ``` 631 | 632 | ## Schema Configuration 633 | 634 | You can configure your schema with additional options: 635 | 636 | ```typescript 637 | const schema = new Schema({ 638 | table_name: 'users', 639 | columns: [...], 640 | uniques: ['email', 'username'], // Composite unique constraints 641 | timestamps: true, // Adds created_at and updated_at columns (default: true) 642 | softDeletes: true // Enables soft delete functionality (default: false) 643 | }); 644 | ``` 645 | 646 | ## Methods 647 | 648 | ### Create 649 | 650 | To insert data we need to import the UserForm and we are going start a new User and insert it inside the UserForm, then we can call the method create. 651 | 652 | ```js 653 | // ./controllers/UserController.ts 654 | import { UserForm } from '../form/UserForm'; 655 | import { User } from '../models/User'; 656 | 657 | const userForm = new UserForm(new User({ first_name, last_name })) 658 | 659 | await User.create(userForm, binding) 660 | 661 | ``` 662 | 663 | ### Read 664 | 665 | By importing the User model will have the following methods to query to D1: 666 | 667 | ```js 668 | // ./controllers/UserController.ts 669 | import { User } from '../models/User'; 670 | 671 | await User.all(binding) 672 | 673 | await User.findById(id, binding) 674 | 675 | await User.findOne(column, value, binding) 676 | 677 | await User.findBy(column, value, binding) 678 | 679 | ``` 680 | 681 | ### Update 682 | 683 | Include the ID and the fields you want to update inside the data object. 684 | 685 | ```js 686 | // ./controllers/UserController.ts 687 | 688 | import { User } from '../models/User'; 689 | 690 | // User.update(data, binding) 691 | await User.update({ id, first_name: 'John' }, binding) 692 | 693 | ``` 694 | 695 | ### Delete 696 | 697 | Delete a User 698 | 699 | ```js 700 | // ./controllers/UserController.ts 701 | 702 | import { User } from '../models/User'; 703 | 704 | await User.delete(id, binding) 705 | 706 | ``` 707 | 708 | ### Raw SQL Queries 709 | 710 | Execute raw SQL queries with the new raw method: 711 | 712 | ```js 713 | // ./controllers/UserController.ts 714 | import { User } from '../models/User'; 715 | 716 | const { success, results } = await User.raw( 717 | `SELECT * FROM users WHERE first_name LIKE '%John%'`, 718 | binding 719 | ); 720 | 721 | if (success) { 722 | console.log(results); 723 | } 724 | ``` 725 | 726 | ## Soft Delete 727 | 728 | When enabled in your schema configuration, soft delete will set the `deleted_at` timestamp instead of removing the record: 729 | 730 | ```typescript 731 | const userSchema = new Schema({ 732 | table_name: 'users', 733 | columns: [...], 734 | softDeletes: true // Enable soft delete 735 | }); 736 | ``` 737 | 738 | When soft delete is enabled: 739 | - `delete()` will update the `deleted_at` field instead of removing the record 740 | - `all()`, `findById()`, `findOne()`, and `findBy()` will automatically filter out soft-deleted records 741 | - You can still access soft-deleted records with raw SQL queries if needed 742 | 743 | ## Extend Methods 744 | 745 | Extend User methods. 746 | 747 | ```js 748 | // ./models/User.ts 749 | import { Model, Schema, NotFoundError } from 'model-one' 750 | import type { SchemaConfigI } from 'model-one'; 751 | import { UserI, UserDataI } from '../interfaces' 752 | 753 | const userSchema: SchemaConfigI = new Schema({ 754 | table_name: 'users', 755 | columns: [ 756 | { name: 'id', type: 'string' }, 757 | { name: 'first_name', type: 'string' }, 758 | { name: 'last_name', type: 'string' } 759 | ], 760 | }) 761 | 762 | export class User extends Model implements UserI { 763 | data: UserDataI 764 | 765 | constructor(props: UserDataI) { 766 | super(userSchema, props) 767 | this.data = props 768 | } 769 | 770 | static async findByFirstName(first_name: string, binding: any) { 771 | // this.findBy(column, value, binding) 772 | return await this.findBy('first_name', first_name, binding) 773 | } 774 | 775 | static async rawAll(binding: any) { 776 | const { results, success } = await binding.prepare(`SELECT * FROM ${userSchema.table_name};`).all() 777 | return Boolean(success) ? results : NotFoundError 778 | } 779 | } 780 | 781 | ``` 782 | 783 | ## To do: 784 | 785 | - [x] Support JSONB 786 | - [x] Enhanced column types and constraints 787 | - [x] Soft and hard delete 788 | - [x] Basic tests 789 | - [ ] Associations: belongs_to, has_one, has_many 790 | - [ ] Complex Forms for multiple Models 791 | 792 | ## Contributors 793 | Julian Clatro 794 | 795 | ## License 796 | MIT 797 | 798 | [npm]: https://www.npmjs.com/ 799 | 800 | [yarn]: https://yarnpkg.com/ 801 | -------------------------------------------------------------------------------- /test/User.spec.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import Joi from 'joi'; 3 | import { Miniflare } from 'miniflare'; 4 | import { Model, Schema, type SchemaConfigI, Form } from '../src'; 5 | 6 | // Helper function for delay 7 | const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 8 | 9 | // Test database schema 10 | export const schema = [ 11 | `CREATE TABLE users (id text PRIMARY KEY, name text UNIQUE, languages text, deleted_at datetime, created_at datetime, updated_at datetime);` 12 | ]; 13 | 14 | // Define validation schema for the form 15 | const joiSchema = Joi.object({ 16 | id: Joi.string(), 17 | name: Joi.string(), 18 | languages: Joi.array(), 19 | }); 20 | 21 | // Schema configuration 22 | const userSchema: SchemaConfigI = new Schema({ 23 | table_name: 'users', 24 | columns: [ 25 | { name: 'id', type: 'string' }, 26 | { name: 'name', type: 'string', constraints: [{ type: 'UNIQUE' }] }, 27 | { name: 'languages', type: 'jsonb' }, 28 | ], 29 | timestamps: true, 30 | softDeletes: true 31 | }); 32 | 33 | // Define interfaces for our model 34 | interface UserDataI { 35 | id?: string; 36 | name?: string; 37 | languages?: string[]; 38 | created_at?: string; 39 | updated_at?: string; 40 | deleted_at?: string; 41 | } 42 | 43 | interface UserI extends Model { 44 | data: UserDataI; 45 | } 46 | 47 | // Form class for validation 48 | export class UserForm extends Form { 49 | constructor(data: UserI) { 50 | super(joiSchema, data); 51 | } 52 | } 53 | 54 | // Model class that extends the base Model 55 | class User extends Model implements UserI { 56 | data: UserDataI; 57 | 58 | constructor(props: UserDataI = {}) { 59 | super(userSchema); 60 | this.data = props || {}; 61 | } 62 | } 63 | 64 | // Helper function to create a user with the given data 65 | async function createUser(data: UserDataI, binding: any): Promise { 66 | const entity = new User(data); 67 | const form = new UserForm(entity); 68 | const createdUser = await User.create(form, binding); 69 | return createdUser; 70 | } 71 | 72 | // Store Miniflare instances to clean up later 73 | const miniflares: any[] = []; 74 | 75 | test.beforeEach(async (t) => { 76 | try { 77 | // Create a Miniflare instance with D1 78 | const mf = new Miniflare({ 79 | modules: true, 80 | script: 'export default {};', 81 | d1Databases: ['TEST_DB'], 82 | }); 83 | 84 | // Get the D1 database 85 | const db = await mf.getD1Database('TEST_DB'); 86 | 87 | // Create users table with simplified SQL syntax for Miniflare v4 88 | await db.exec(schema[0].trim()); 89 | 90 | // Store context for the test` 91 | t.context = { db, mf }; 92 | 93 | // Store instance for cleanup 94 | miniflares.push(mf); 95 | 96 | console.log('✅ Test database initialized with users schema'); 97 | } catch (error) { 98 | console.error('❌ Error in test setup:', error); 99 | throw error; 100 | } 101 | }); 102 | 103 | // Cleanup Miniflare instances after tests 104 | test.after.always(() => { 105 | for (const mf of miniflares) { 106 | if (mf && typeof mf.dispose === 'function') { 107 | mf.dispose(); 108 | } 109 | } 110 | }); 111 | 112 | 113 | // User CRUD Tests 114 | test('Create a user with basic data', async (t) => { 115 | const { db: binding }: any = t.context; 116 | const user = await createUser({ name: 'John' }, binding); 117 | 118 | // Verify user was created with correct data 119 | t.is(user.data.name, 'John'); 120 | t.truthy(user.data.id, 'User should have an auto-generated ID'); 121 | }); 122 | 123 | test('Create a user with JSON data (languages array)', async (t) => { 124 | const { db: binding }: any = t.context; 125 | const languages = ['es', 'en']; 126 | const user = await createUser({ name: 'John', languages }, binding); 127 | 128 | // Verify user was created with correct data 129 | t.is(user.data.name, 'John'); 130 | t.deepEqual(user.data.languages, languages); 131 | t.is(typeof user.data.languages, 'object'); 132 | t.true(Array.isArray(user.data.languages), 'Languages should be an array'); 133 | }); 134 | 135 | test('Create and update user with JSON data using instance.update()', async (t) => { 136 | const { db: binding }: any = t.context; 137 | 138 | // Create initial user - this should be a full User instance now 139 | const initialLanguages = ['es', 'en']; 140 | const userInstance = await createUser({ name: 'John', languages: initialLanguages }, binding) as User; 141 | 142 | // Verify initial data on the instance 143 | t.truthy(userInstance, 'createUser should return a User instance.'); 144 | if (!userInstance) return t.fail('User instance not created.'); 145 | 146 | t.is(userInstance.data.name, 'John'); 147 | t.deepEqual(userInstance.data.languages, initialLanguages); 148 | t.truthy(userInstance.id, 'User instance should have an ID.'); 149 | const originalCreatedAt = userInstance.data.created_at; // Assuming created_at is populated 150 | 151 | // Introduce a delay to ensure updated_at can differ from created_at 152 | await delay(1100); 153 | 154 | // Define changes for the update - no ID needed here for instance.update() 155 | const updatedLanguages = ['es', 'en', 'fr']; 156 | const changesToApply: Partial = { 157 | name: 'Mochis Deluxe', 158 | languages: updatedLanguages 159 | }; 160 | 161 | // Call instance.update() 162 | const updatedUser = await userInstance.update(changesToApply, binding); 163 | 164 | // 1. Verify the instance returned by update() 165 | t.truthy(updatedUser, 'instance.update() should return the updated instance.'); 166 | if (!updatedUser) return t.fail('instance.update() did not return an instance.'); 167 | 168 | t.is(updatedUser.data.name, 'Mochis Deluxe', 'Returned instance name should be updated.'); 169 | t.deepEqual(updatedUser.data.languages, updatedLanguages, 'Returned instance languages should be updated.'); 170 | t.truthy(updatedUser.data.updated_at, 'Returned instance should have updated_at timestamp.'); 171 | if (originalCreatedAt && updatedUser.data.updated_at) { 172 | t.not(updatedUser.data.updated_at, originalCreatedAt, 'updated_at should be different from created_at.'); 173 | } 174 | 175 | // 2. Verify the original userInstance is also mutated 176 | t.is(userInstance.data.name, 'Mochis Deluxe', 'Original instance name should reflect update.'); 177 | t.deepEqual(userInstance.data.languages, updatedLanguages, 'Original instance languages should reflect update.'); 178 | t.is(userInstance.data.updated_at, updatedUser.data.updated_at, 'Original instance updated_at should match returned instance.'); 179 | 180 | // 3. Verify retrieval of updated user from DB 181 | const retrievedAfterUpdate = await User.findById(userInstance.id!, binding) as User; 182 | t.truthy(retrievedAfterUpdate, 'Should be able to retrieve user after update.'); 183 | if (!retrievedAfterUpdate) return t.fail('Failed to retrieve user after update.'); 184 | 185 | t.is(retrievedAfterUpdate.data.name, 'Mochis Deluxe', 'Persisted name should be updated.'); 186 | t.deepEqual(retrievedAfterUpdate.data.languages, updatedLanguages, 'Persisted languages should be updated.'); 187 | t.is(retrievedAfterUpdate.data.created_at, originalCreatedAt, 'created_at should remain unchanged after update.'); 188 | t.is(retrievedAfterUpdate.data.updated_at, updatedUser.data.updated_at, 'Persisted updated_at should match.'); 189 | }); 190 | 191 | test('Find user by ID', async (t) => { 192 | const { db: binding }: any = t.context; 193 | 194 | // Create a user to find 195 | const user = await createUser({ name: 'FindMe', languages: ['en'] }, binding); 196 | 197 | // Find the user by ID 198 | const foundUser = await User.findById(user.data.id, binding); 199 | 200 | // Verify the user was found with correct data 201 | t.not(foundUser, null); 202 | if (foundUser) { 203 | t.is(foundUser.data.name, 'FindMe'); 204 | t.deepEqual(foundUser.data.languages, ['en']); 205 | } 206 | }); 207 | 208 | test('Delete a user (soft delete)', async (t) => { 209 | const { db: binding }: any = t.context; 210 | 211 | // Create a user to delete 212 | const user = await createUser({ name: 'ToDelete' }, binding); 213 | 214 | // Soft delete the user 215 | await User.delete(user.data.id, binding); 216 | 217 | // Try to find the user - should return null due to soft delete 218 | const notFound = await User.findById(user.data.id, binding); 219 | t.is(notFound, null, 'User should not be found after deletion'); 220 | 221 | // Verify it still exists in DB by running a raw query that ignores deleted_at 222 | const { results } = await User.query( 223 | `SELECT * FROM users WHERE id='${user.data.id}'`, 224 | binding 225 | ); 226 | 227 | // Should have exactly one result and deleted_at should be set 228 | t.is(results.length, 1, 'User should still exist in the database'); 229 | t.truthy(results[0].deleted_at, 'User should have deleted_at timestamp set'); 230 | }); 231 | 232 | // Instance Delete Method Tests 233 | test('Instance can call delete() successfully (soft delete)', async (t) => { 234 | const { db: binding }: any = t.context; 235 | const userData = { name: 'DeleteMeInstance' }; 236 | const userInstance = await createUser(userData, binding) as User; 237 | 238 | t.truthy(userInstance, 'User instance should be created.'); 239 | t.truthy(userInstance.id, 'User instance should have an ID.'); 240 | const userId = userInstance.id!; 241 | 242 | // Call instance delete() 243 | const deleteResult = await userInstance.delete(binding); 244 | t.truthy(deleteResult.message.includes('soft deleted'), 'Instance delete should confirm soft deletion.'); 245 | 246 | // Verify the instance data is not nulled out immediately by instance.delete() 247 | // The static delete method, which instance.delete() calls, doesn't modify the instance itself 248 | t.is(userInstance.data.name, 'DeleteMeInstance', 'Instance data should remain after calling delete.'); 249 | 250 | // Try to find the user by ID using static User.findById, it should not be found (due to soft delete) 251 | const notFoundUser = await User.findById(userId, binding); 252 | t.is(notFoundUser, null, 'User should not be found without includeDeleted flag after soft delete.'); 253 | 254 | // Verify soft delete by querying with includeDeleted: true 255 | const softDeletedUser = await User.findById(userId, binding, true) as User; 256 | t.truthy(softDeletedUser, 'User should be retrievable when includeDeleted is true.'); 257 | const softDeletedUser2 = await User.findById(userId, binding, true) as User; 258 | t.truthy(softDeletedUser2, 'User (softDeletedUser2, includeDeleted: true) should be retrievable.'); 259 | 260 | // Assertions for softDeletedUser - now a model instance 261 | t.truthy(softDeletedUser!.data.deleted_at, 'softDeletedUser.data.deleted_at should have a timestamp.'); 262 | t.is(softDeletedUser!.id, userId, 'softDeletedUser.id should match.'); 263 | t.is(softDeletedUser!.data.id, userId, 'softDeletedUser.data.id should match.'); 264 | 265 | // Assertions for softDeletedUser2 - now also a model instance 266 | t.truthy(softDeletedUser2!.data.deleted_at, 'softDeletedUser2.data.deleted_at should have a timestamp.'); 267 | t.is(softDeletedUser2!.id, userId, 'softDeletedUser2.id should match.'); 268 | t.is(softDeletedUser2!.data.id, userId, 'softDeletedUser2.data.id should match.'); 269 | }); 270 | 271 | test('Instance delete() throws error if ID is missing', async (t) => { 272 | const { db: binding }: any = t.context; 273 | const userInstance = new User({ name: 'NoIDUser' }); 274 | // Ensure ID is not set 275 | userInstance.data.id = undefined; 276 | userInstance.id = null; 277 | 278 | try { 279 | await userInstance.delete(binding); 280 | t.fail('Should have thrown an error because ID is missing.'); 281 | } catch (error: any) { 282 | t.true(error instanceof Error, 'Error should be an instance of Error.'); // Or ModelError if ModelError is exported and used 283 | t.is(error.message, 'Instance data is missing an ID, cannot delete.', 'Error message not as expected.'); 284 | } 285 | }); 286 | 287 | test('Instance delete() performs soft delete when schema is configured', async (t) => { 288 | const { db: binding }: any = t.context; 289 | // User schema in this test file is already configured for softDeletes: true 290 | const userData = { name: 'SoftDeleteTestInstance' }; 291 | const userInstance = await createUser(userData, binding) as User; 292 | 293 | t.truthy(userInstance, 'User instance should be created for soft delete test.'); 294 | t.truthy(userInstance.id, 'User instance should have an ID.'); 295 | const userId = userInstance.id!; 296 | 297 | const deleteResult = await userInstance.delete(binding); 298 | t.truthy(deleteResult.message.includes('soft deleted'), 'Confirmation message should indicate soft delete.'); 299 | 300 | // Attempt to find the user normally - should not be found. 301 | const foundNormally = await User.findById(userId, binding); 302 | t.is(foundNormally, null, 'User should not be found with default findById after soft delete.'); 303 | 304 | // Attempt to find including deleted - should be found. 305 | const foundWithSoftDelete = await User.findById(userId, binding, true) as User; 306 | t.truthy(foundWithSoftDelete, 'User should be found when including deleted records.'); 307 | t.truthy(foundWithSoftDelete!.data.deleted_at, 'User data should have a deleted_at timestamp.'); 308 | t.is(foundWithSoftDelete!.id, userId, 'Found user ID should match original ID.'); 309 | 310 | // Also, verify that a direct query for the ID still returns the row, but with deleted_at set 311 | const queryResponse = await User.query(`SELECT * FROM users WHERE id = '${userId}'`, binding); 312 | t.truthy(queryResponse.success && queryResponse.results && queryResponse.results.length > 0, 'Raw query should find the user.'); 313 | if (queryResponse.success && queryResponse.results && queryResponse.results.length > 0) { 314 | t.truthy(queryResponse.results[0].deleted_at, 'Raw query result should have deleted_at populated.'); 315 | } 316 | }); 317 | 318 | // Unique Constraint and FindOne Tests 319 | test('Unique constraint on name', async (t) => { 320 | const { db: binding }: any = t.context; 321 | const uniqueName = 'UniqueName'; 322 | const user1 = await createUser({ name: uniqueName }, binding); 323 | try { 324 | await createUser({ name: uniqueName }, binding); 325 | t.fail('Should have thrown an error due to unique constraint violation.'); 326 | } catch (error: any) { 327 | t.true(error instanceof Error, 'Error should be an instance of Error.'); // Or ModelError if ModelError is exported and used 328 | t.is(error.message, 'D1_ERROR: UNIQUE constraint failed: users.name: SQLITE_CONSTRAINT', 'Error message not as expected.'); 329 | } 330 | }); 331 | 332 | test('Find one user by name', async (t) => { 333 | const { db: binding }: any = t.context; 334 | const name = 'FindOneUser'; 335 | const user = await createUser({ name }, binding); 336 | const foundUser = await User.findOne('name', name, binding); 337 | t.truthy(foundUser, 'User should be found.'); 338 | t.is(foundUser!.data.name, name, 'Found user name should match.'); 339 | }); 340 | 341 | // --- Test Suite for Model.instance.save() --- 342 | test.serial('instance.save() correctly dispatches to create() for new records and updates instance', async (t) => { 343 | const { db: binding }: any = t.context; 344 | const initialName = 'SaveCreate Test User'; 345 | const initialLanguages = ['typescript', 'javascript']; 346 | 347 | const userInstance = new User({ name: initialName, languages: initialLanguages }); 348 | console.log('[Test: save->create] Initial userInstance.id:', userInstance.id); 349 | console.log('[Test: save->create] Initial userInstance.data:', JSON.stringify(userInstance.data)); 350 | 351 | // Call save() on the new instance 352 | const savedUserInstance = await userInstance.save(binding); 353 | console.log('[Test: save->create] userInstance.save() returned:', JSON.stringify(savedUserInstance)); 354 | 355 | // Log state of the original instance (it should be updated) 356 | console.log('[Test: save->create] Original userInstance.id after save:', userInstance.id); 357 | console.log('[Test: save->create] Original userInstance.data after save:', JSON.stringify(userInstance.data)); 358 | console.log('[Test: save->create] Returned savedUserInstance.id after save:', savedUserInstance?.id); 359 | console.log('[Test: save->create] Returned savedUserInstance.data after save:', JSON.stringify(savedUserInstance?.data)); 360 | 361 | // --- EXPECTED CONSOLE OUTPUT (for manual verification before assertions) --- 362 | console.log(`[Test: save->create] EXPECTED: savedUserInstance to be the same object as userInstance.`); 363 | console.log(`[Test: save->create] EXPECTED: userInstance.id to be a non-null string (newly created ID).`); 364 | console.log(`[Test: save->create] EXPECTED: userInstance.data.name to be '${initialName}'.`); 365 | console.log(`[Test: save->create] EXPECTED: userInstance.data.languages to be ${JSON.stringify(initialLanguages)}.`); 366 | console.log(`[Test: save->create] EXPECTED: userInstance.data.created_at to be a non-null string.`); 367 | console.log(`[Test: save->create] EXPECTED: userInstance.data.updated_at to be a non-null string.`); 368 | 369 | // Assertions 370 | t.truthy(savedUserInstance, 'save() should return the instance for a new record.'); 371 | t.is(savedUserInstance, userInstance, 'save() should return the same instance (this).'); 372 | t.truthy(userInstance.id, 'Instance ID (userInstance.id) should be populated after save (create).'); 373 | t.is(userInstance.data.name, initialName, 'Instance data.name should be correct after save (create).'); 374 | t.deepEqual(userInstance.data.languages, initialLanguages, 'Instance data.languages should be correct after save (create).'); 375 | t.truthy(userInstance.data.created_at, 'created_at should be populated on userInstance.data.'); 376 | t.truthy(userInstance.data.updated_at, 'updated_at should be populated on userInstance.data.'); 377 | 378 | // Verify data in DB 379 | if (userInstance.id) { 380 | const retrievedUser = await User.findById(userInstance.id, binding); 381 | console.log('[Test: save->create] Retrieved user from DB by ID:', JSON.stringify(retrievedUser)); 382 | t.truthy(retrievedUser, 'User should be findable in DB after save (create).'); 383 | if (retrievedUser) { 384 | t.is(retrievedUser.data.name, initialName, 'DB: Name should match.'); 385 | t.deepEqual(retrievedUser.data.languages, initialLanguages, 'DB: Languages should match.'); 386 | t.is(retrievedUser.data.id, userInstance.id, 'DB: ID should match.'); 387 | } 388 | } else { 389 | console.error('[Test: save->create] CRITICAL: userInstance.id is not set after save, cannot verify DB state.'); 390 | t.fail('User ID not set after save, cannot verify DB state.'); 391 | } 392 | t.pass('Create test finished. Review logs.'); 393 | }); 394 | 395 | test.serial('instance.save() correctly dispatches to update() for existing records and updates instance', async (t) => { 396 | const { db: binding }: any = t.context; 397 | const initialName = 'SaveUpdate Test User Initial'; 398 | const initialLanguages = ['go', 'python']; 399 | 400 | // Step 1: Create an initial user (can use .save() for this as it's tested above) 401 | const userInstance = new User({ name: initialName, languages: initialLanguages }); 402 | console.log('[Test: save->update] Before initial save - userInstance.id:', userInstance.id); 403 | console.log('[Test: save->update] Before initial save - userInstance.data:', JSON.stringify(userInstance.data)); 404 | await userInstance.save(binding); // Create the record 405 | 406 | const originalId = userInstance.id; 407 | const originalCreatedAt = userInstance.data.created_at; 408 | console.log('[Test: save->update] After initial save (create) - userInstance.id:', originalId); 409 | console.log('[Test: save->update] After initial save (create) - userInstance.data:', JSON.stringify(userInstance.data)); 410 | 411 | t.truthy(originalId, 'Pre-condition: User must have an ID after initial save.'); 412 | if (!originalId) { 413 | console.error('[Test: save->update] CRITICAL: Failed to create user for update test.'); 414 | return t.fail('Failed to create user for update test.'); 415 | } 416 | 417 | // Introduce a delay to ensure updated_at can differ from created_at if system is too fast 418 | await delay(1100); 419 | 420 | // Step 2: Modify the instance's data 421 | const updatedName = 'SaveUpdate Test User Updated'; 422 | const updatedLanguages = ['go', 'rust', 'c++']; 423 | userInstance.data.name = updatedName; 424 | userInstance.data.languages = updatedLanguages; 425 | // DO NOT change userInstance.id here, it should remain the same for an update. 426 | console.log('[Test: save->update] Instance data before calling save() for update - userInstance.id:', userInstance.id); 427 | console.log('[Test: save->update] Instance data before calling save() for update - userInstance.data:', JSON.stringify(userInstance.data)); 428 | 429 | // Step 3: Call save() on the existing, modified instance 430 | const savedUserInstance = await userInstance.save(binding); 431 | console.log('[Test: save->update] userInstance.save() returned for update:', JSON.stringify(savedUserInstance)); 432 | 433 | // Log state of the original instance (it should be updated) 434 | console.log('[Test: save->update] Original userInstance.id after save (update):', userInstance.id); 435 | console.log('[Test: save->update] Original userInstance.data after save (update):', JSON.stringify(userInstance.data)); 436 | console.log('[Test: save->update] Returned savedUserInstance.id after save (update):', savedUserInstance?.id); 437 | console.log('[Test: save->update] Returned savedUserInstance.data after save (update):', JSON.stringify(savedUserInstance?.data)); 438 | 439 | // --- EXPECTED CONSOLE OUTPUT (for manual verification before assertions) --- 440 | console.log(`[Test: save->update] EXPECTED: savedUserInstance to be the same object as userInstance.`); 441 | console.log(`[Test: save->update] EXPECTED: userInstance.id to be '${originalId}' (ID should not change on update).`); 442 | console.log(`[Test: save->update] EXPECTED: userInstance.data.name to be '${updatedName}'.`); 443 | console.log(`[Test: save->update] EXPECTED: userInstance.data.languages to be ${JSON.stringify(updatedLanguages)}.`); 444 | console.log(`[Test: save->update] EXPECTED: userInstance.data.created_at to be '${originalCreatedAt}' (created_at should not change).`); 445 | console.log(`[Test: save->update] EXPECTED: userInstance.data.updated_at to be a new timestamp, different from created_at.`); 446 | 447 | // Assertions 448 | t.truthy(savedUserInstance, 'save() should return the instance for an update.'); 449 | t.is(savedUserInstance, userInstance, 'save() should return the same instance (this) for update.'); 450 | t.is(userInstance.id, originalId, 'Instance ID (userInstance.id) should remain the same after save (update).'); 451 | t.is(userInstance.data.name, updatedName, 'Instance data.name should be updated.'); 452 | t.deepEqual(userInstance.data.languages, updatedLanguages, 'Instance data.languages should be updated.'); 453 | t.is(userInstance.data.created_at, originalCreatedAt, 'created_at should not change on update.'); 454 | t.truthy(userInstance.data.updated_at, 'updated_at should be populated on userInstance.data.'); 455 | if (originalCreatedAt && userInstance.data.updated_at) { 456 | t.not(userInstance.data.updated_at, originalCreatedAt, 'updated_at should be different from original created_at.'); 457 | } 458 | 459 | // Verify data in DB 460 | if (userInstance.id) { 461 | const retrievedUser = await User.findById(userInstance.id, binding); 462 | console.log('[Test: save->update] Retrieved user from DB by ID:', JSON.stringify(retrievedUser)); 463 | t.truthy(retrievedUser, 'User should be findable in DB after save (update).'); 464 | if (retrievedUser) { 465 | t.is(retrievedUser.data.name, updatedName, 'DB: Name should be updated.'); 466 | t.deepEqual(retrievedUser.data.languages, updatedLanguages, 'DB: Languages should be updated.'); 467 | t.is(retrievedUser.data.id, originalId, 'DB: ID should remain the same.'); 468 | t.is(retrievedUser.data.created_at, originalCreatedAt, 'DB: created_at should remain the same.'); 469 | } 470 | } else { 471 | console.error('[Test: save->update] CRITICAL: userInstance.id is not set, cannot verify DB state for update.'); 472 | t.fail('User ID not set, cannot verify DB state for update.'); 473 | } 474 | t.pass('Update test finished. Review logs.'); 475 | }); 476 | -------------------------------------------------------------------------------- /test/JoiValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import Joi from 'joi'; 3 | import { Miniflare } from 'miniflare'; 4 | 5 | // Define a test model with various validation rules 6 | interface IValidationTestModel { 7 | id?: string; 8 | requiredString?: string; 9 | optionalString?: string; 10 | numberField?: number; 11 | booleanField?: boolean; 12 | dateField?: Date; 13 | jsonField?: object; 14 | emailField?: string; 15 | constrainedString?: string; 16 | numberWithRange?: number; 17 | nestedJsonField?: { 18 | item: string; 19 | count: number; 20 | active?: boolean; 21 | }; 22 | stringArrayField?: string[]; 23 | numberArrayField?: number[]; 24 | hasExtraDetails?: boolean; 25 | extraDetails?: string; 26 | customValidatedField?: string; 27 | fieldWithCustomMessage?: number; 28 | createdAt?: Date; 29 | updatedAt?: Date; 30 | } 31 | 32 | class ValidationTestModel { 33 | static tableName = 'validation_tests'; 34 | static d1Binding = 'VALIDATION_DB'; 35 | 36 | static schemaConfig = { 37 | id: { type: 'TEXT', primaryKey: true }, 38 | requiredString: { type: 'TEXT' }, 39 | optionalString: { type: 'TEXT' }, 40 | numberField: { type: 'INTEGER' }, 41 | booleanField: { type: 'BOOLEAN' }, 42 | dateField: { type: 'TEXT' }, // Store as TEXT in D1, validated as Date 43 | jsonField: { type: 'TEXT' }, // Original generic JSON field 44 | emailField: { type: 'TEXT' }, 45 | constrainedString: { type: 'TEXT' }, 46 | numberWithRange: { type: 'INTEGER' }, 47 | nestedJsonField: { type: 'TEXT' }, 48 | stringArrayField: { type: 'TEXT' }, 49 | numberArrayField: { type: 'TEXT' }, 50 | hasExtraDetails: { type: 'BOOLEAN' }, 51 | extraDetails: { type: 'TEXT' }, 52 | customValidatedField: { type: 'TEXT' }, 53 | fieldWithCustomMessage: { type: 'INTEGER' }, 54 | createdAt: { type: 'TEXT' }, // Usually TEXT for ISO strings 55 | updatedAt: { type: 'TEXT' }, // Usually TEXT for ISO strings 56 | }; 57 | 58 | static getValidationSchema(joi: typeof Joi): Joi.ObjectSchema { 59 | // Custom Joi validation method 60 | const customStringValidation = (value: string, helpers: Joi.CustomHelpers) => { 61 | if (value && !value.includes('valid_substring')) { 62 | return helpers.error('string.customValidation', { v: value }); 63 | } 64 | return value; 65 | }; 66 | 67 | return joi.object({ 68 | id: joi.string().uuid(), 69 | requiredString: joi.string().required(), 70 | optionalString: joi.string().allow(null, ''), 71 | numberField: joi.number(), 72 | booleanField: joi.boolean(), 73 | dateField: joi.date().iso(), 74 | jsonField: joi.object(), // Original generic JSON field validation 75 | emailField: joi.string().email(), 76 | constrainedString: joi.string().min(3).max(10), 77 | numberWithRange: joi.number().min(5).max(100), 78 | nestedJsonField: joi.object({ 79 | item: joi.string().required(), 80 | count: joi.number().integer().positive().required(), 81 | active: joi.boolean().optional() 82 | }).optional(), // Making the whole nested object optional for now 83 | stringArrayField: joi.array().items(joi.string()).optional(), 84 | numberArrayField: joi.array().items(joi.number()).optional(), 85 | hasExtraDetails: joi.boolean().optional(), 86 | extraDetails: joi.string().when('hasExtraDetails', { 87 | is: true, 88 | then: joi.required(), 89 | otherwise: joi.optional() 90 | }), 91 | customValidatedField: joi.string().custom(customStringValidation, 'custom string validation').optional() 92 | .messages({ 93 | 'string.customValidation': '{#label} must contain "valid_substring", but received "{#v}"' 94 | }), 95 | fieldWithCustomMessage: joi.number().min(10).max(20).optional() 96 | .messages({ 97 | 'number.min': '{#label} must be at least 10, pal!', 98 | 'number.max': 'Whoa there, {#label} cannot be more than 20.', 99 | 'number.base': '{#label} needs to be a number, friend.' 100 | }), 101 | createdAt: joi.date().iso().optional(), 102 | updatedAt: joi.date().iso().optional(), 103 | }); 104 | } 105 | } 106 | 107 | // Test setup for Miniflare 108 | const mf = new Miniflare({ 109 | modules: true, 110 | script: "", // No worker script, just D1 111 | d1Databases: { [ValidationTestModel.d1Binding]: ':memory:' }, 112 | }); 113 | 114 | // Helper to get DB instance 115 | async function getDb() { 116 | return mf.getD1Database(ValidationTestModel.d1Binding); 117 | } 118 | 119 | // Initialize schema before tests 120 | test.before(async t => { 121 | const db = await getDb(); 122 | // Manually construct SQL as generateSchemaSQL is not available in the current Model version 123 | const columns = Object.entries(ValidationTestModel.schemaConfig) 124 | .map(([name, config_item]) => { 125 | const config = config_item as any; // Cast to any to access potential properties 126 | let columnDef = `${name} ${config.type}`; 127 | if (config.primaryKey) columnDef += ' PRIMARY KEY'; 128 | // Add other constraints like NOT NULL if defined in config.required, etc. 129 | // For now, keeping it simple based on current schemaConfig structure. 130 | return columnDef; 131 | }) 132 | .join(', '); 133 | const schemaSql = `CREATE TABLE IF NOT EXISTS ${ValidationTestModel.tableName} (${columns});`; 134 | 135 | if (typeof schemaSql === 'string') { 136 | await db.exec(schemaSql.trim()); 137 | } else { 138 | console.error('Schema SQL is not in expected format (string).'); 139 | } 140 | }); 141 | 142 | test.after.always(async () => { 143 | await mf.dispose(); 144 | }); 145 | 146 | // --- Basic Validation Tests (With Assertions) --- 147 | 148 | test.serial('Basic Validations: Required Fields', async t => { 149 | const schema = ValidationTestModel.getValidationSchema(Joi); 150 | const commonFields = { 151 | numberField: 123, 152 | booleanField: true, 153 | dateField: new Date().toISOString(), 154 | jsonField: { data: 'test' }, 155 | emailField: 'test@example.com', 156 | constrainedString: 'valid', 157 | numberWithRange: 10, 158 | }; 159 | 160 | // Test missing required field 161 | const dataMissingRequired = { ...commonFields }; // requiredString is missing 162 | const resultMissing = schema.validate(dataMissingRequired, { abortEarly: false }); 163 | t.truthy(resultMissing.error, 'Error should exist when requiredString is missing'); 164 | t.is(resultMissing.error?.details.length, 1, 'Should have one error detail'); 165 | t.is(resultMissing.error?.details[0].message, '"requiredString" is required', 'Correct error message for missing requiredString'); 166 | t.deepEqual(resultMissing.error?.details[0].path, ['requiredString'], 'Correct error path for missing requiredString'); 167 | t.is(resultMissing.error?.details[0].type, 'any.required', 'Correct error type for missing requiredString'); 168 | 169 | // Test with required field present 170 | const dataWithRequired = { ...commonFields, requiredString: 'Hello' }; 171 | const resultWith = schema.validate(dataWithRequired, { abortEarly: false }); 172 | t.falsy(resultWith.error, 'Error should not exist when requiredString is present'); 173 | }); 174 | 175 | test.serial('Basic Validations: Data Types', async t => { 176 | const schema = ValidationTestModel.getValidationSchema(Joi); 177 | const baseData: any = { requiredString: 'test' }; // satisfy required field 178 | 179 | // String (optionalString) 180 | let data: any = { ...baseData, optionalString: 123 }; // Invalid type 181 | let result = schema.validate(data, { abortEarly: false }); 182 | t.truthy(result.error, 'Error for number as optionalString'); 183 | t.is(result.error?.details[0].message, '"optionalString" must be a string'); 184 | t.deepEqual(result.error?.details[0].path, ['optionalString']); 185 | t.is(result.error?.details[0].type, 'string.base'); 186 | 187 | data = { ...baseData, optionalString: 'valid string' }; // Valid type 188 | result = schema.validate(data, { abortEarly: false }); 189 | t.falsy(result.error, 'No error for valid optionalString'); 190 | 191 | // Number (numberField) 192 | data = { ...baseData, numberField: 'not a number' }; // Invalid type 193 | result = schema.validate(data, { abortEarly: false }); 194 | t.truthy(result.error, 'Error for string as numberField'); 195 | t.is(result.error?.details[0].message, '"numberField" must be a number'); 196 | t.deepEqual(result.error?.details[0].path, ['numberField']); 197 | t.is(result.error?.details[0].type, 'number.base'); 198 | 199 | data = { ...baseData, numberField: 42 }; // Valid type 200 | result = schema.validate(data, { abortEarly: false }); 201 | t.falsy(result.error, 'No error for valid numberField'); 202 | 203 | // Boolean (booleanField) 204 | data = { ...baseData, booleanField: 'not a boolean' }; // Invalid type 205 | result = schema.validate(data, { abortEarly: false }); 206 | t.truthy(result.error, 'Error for string as booleanField'); 207 | t.is(result.error?.details[0].message, '"booleanField" must be a boolean'); 208 | t.deepEqual(result.error?.details[0].path, ['booleanField']); 209 | t.is(result.error?.details[0].type, 'boolean.base'); 210 | 211 | data = { ...baseData, booleanField: true }; // Valid type 212 | result = schema.validate(data, { abortEarly: false }); 213 | t.falsy(result.error, 'No error for valid booleanField'); 214 | 215 | // Date (dateField) 216 | data = { ...baseData, dateField: 'not a date' }; // Invalid format 217 | result = schema.validate(data, { abortEarly: false }); 218 | t.truthy(result.error, 'Error for invalid date string for dateField'); 219 | t.is(result.error?.details[0].message, '"dateField" must be in ISO 8601 date format'); 220 | t.deepEqual(result.error?.details[0].path, ['dateField']); 221 | t.is(result.error?.details[0].type, 'date.format'); 222 | 223 | data = { ...baseData, dateField: new Date().toISOString() }; // Valid ISO string 224 | result = schema.validate(data, { abortEarly: false }); 225 | t.falsy(result.error, 'No error for valid ISO date string for dateField'); 226 | 227 | data = { ...baseData, dateField: new Date() }; // Valid Date object 228 | result = schema.validate(data, { abortEarly: false }); 229 | t.falsy(result.error, 'No error for valid Date object for dateField'); 230 | 231 | // JSON (jsonField - basic object) 232 | data = { ...baseData, jsonField: 'not an object' }; // Invalid type 233 | result = schema.validate(data, { abortEarly: false }); 234 | t.truthy(result.error, 'Error for string as jsonField'); 235 | t.is(result.error?.details[0].message, '"jsonField" must be of type object'); 236 | t.deepEqual(result.error?.details[0].path, ['jsonField']); 237 | t.is(result.error?.details[0].type, 'object.base'); 238 | 239 | data = { ...baseData, jsonField: { key: 'value' } }; // Valid object 240 | result = schema.validate(data, { abortEarly: false }); 241 | t.falsy(result.error, 'No error for valid object for jsonField'); 242 | }); 243 | 244 | test.serial('Basic Validations: Field Constraints', async t => { 245 | const schema = ValidationTestModel.getValidationSchema(Joi); 246 | const baseData: any = { requiredString: 'constraints test' }; // satisfy required field 247 | 248 | // constrainedString (min:3, max:10) 249 | let data: any = { ...baseData, constrainedString: 'hi' }; // Too short 250 | let result = schema.validate(data, { abortEarly: false }); 251 | t.truthy(result.error, 'Error for too short constrainedString'); 252 | t.is(result.error?.details[0].message, '"constrainedString" length must be at least 3 characters long'); 253 | t.deepEqual(result.error?.details[0].path, ['constrainedString']); 254 | t.is(result.error?.details[0].type, 'string.min'); 255 | 256 | data = { ...baseData, constrainedString: 'waytoolongstring' }; // Too long 257 | result = schema.validate(data, { abortEarly: false }); 258 | t.truthy(result.error, 'Error for too long constrainedString'); 259 | t.is(result.error?.details[0].message, '"constrainedString" length must be less than or equal to 10 characters long'); 260 | t.deepEqual(result.error?.details[0].path, ['constrainedString']); 261 | t.is(result.error?.details[0].type, 'string.max'); 262 | 263 | data = { ...baseData, constrainedString: 'valid' }; // Valid length 264 | result = schema.validate(data, { abortEarly: false }); 265 | t.falsy(result.error, 'No error for valid length constrainedString'); 266 | 267 | // numberWithRange (min:5, max:100) 268 | data = { ...baseData, numberWithRange: 4 }; // Too small 269 | result = schema.validate(data, { abortEarly: false }); 270 | t.truthy(result.error, 'Error for too small numberWithRange'); 271 | t.is(result.error?.details[0].message, '"numberWithRange" must be greater than or equal to 5'); 272 | t.deepEqual(result.error?.details[0].path, ['numberWithRange']); 273 | t.is(result.error?.details[0].type, 'number.min'); 274 | 275 | data = { ...baseData, numberWithRange: 101 }; // Too large 276 | result = schema.validate(data, { abortEarly: false }); 277 | t.truthy(result.error, 'Error for too large numberWithRange'); 278 | t.is(result.error?.details[0].message, '"numberWithRange" must be less than or equal to 100'); 279 | t.deepEqual(result.error?.details[0].path, ['numberWithRange']); 280 | t.is(result.error?.details[0].type, 'number.max'); 281 | 282 | data = { ...baseData, numberWithRange: 50 }; // Valid range 283 | result = schema.validate(data, { abortEarly: false }); 284 | t.falsy(result.error, 'No error for valid range numberWithRange'); 285 | 286 | // emailField 287 | data = { ...baseData, emailField: 'notanemail' }; // Invalid email 288 | result = schema.validate(data, { abortEarly: false }); 289 | t.truthy(result.error, 'Error for invalid emailField'); 290 | t.is(result.error?.details[0].message, '"emailField" must be a valid email'); 291 | t.deepEqual(result.error?.details[0].path, ['emailField']); 292 | t.is(result.error?.details[0].type, 'string.email'); 293 | 294 | data = { ...baseData, emailField: 'valid@email.com' }; // Valid email 295 | result = schema.validate(data, { abortEarly: false }); 296 | t.falsy(result.error, 'No error for valid emailField'); 297 | }); 298 | 299 | // --- Complex Schema Validation Tests (With Assertions) --- 300 | 301 | test.serial('Complex Schema: Nested JSON Object', async t => { 302 | const schema = ValidationTestModel.getValidationSchema(Joi); 303 | const baseData: any = { requiredString: 'nested_json_test' }; 304 | 305 | // Valid nested JSON 306 | let data: any = { 307 | ...baseData, 308 | nestedJsonField: { item: 'Test Item', count: 5, active: true } 309 | }; 310 | let result = schema.validate(data, { abortEarly: false }); 311 | t.falsy(result.error, 'Valid nested JSON should not have an error'); 312 | 313 | // Invalid nested JSON - missing required 'item' 314 | data = { 315 | ...baseData, 316 | nestedJsonField: { count: 10 } // 'item' is missing 317 | }; 318 | result = schema.validate(data, { abortEarly: false }); 319 | t.truthy(result.error, 'Error for missing required item in nested JSON'); 320 | t.is(result.error?.details[0].message, '"nestedJsonField.item" is required'); 321 | t.deepEqual(result.error?.details[0].path, ['nestedJsonField', 'item']); 322 | t.is(result.error?.details[0].type, 'any.required'); 323 | 324 | // Invalid nested JSON - 'count' wrong type 325 | data = { 326 | ...baseData, 327 | nestedJsonField: { item: 'Another Item', count: 'five' } // 'count' is a string 328 | }; 329 | result = schema.validate(data, { abortEarly: false }); 330 | t.truthy(result.error, 'Error for wrong type of count in nested JSON'); 331 | t.is(result.error?.details[0].message, '"nestedJsonField.count" must be a number'); 332 | t.deepEqual(result.error?.details[0].path, ['nestedJsonField', 'count']); 333 | t.is(result.error?.details[0].type, 'number.base'); 334 | 335 | // Invalid nested JSON - 'count' not positive 336 | data = { 337 | ...baseData, 338 | nestedJsonField: { item: 'Item C', count: 0 } // 'count' is 0 339 | }; 340 | result = schema.validate(data, { abortEarly: false }); 341 | t.truthy(result.error, 'Error for non-positive count in nested JSON'); 342 | t.is(result.error?.details[0].message, '"nestedJsonField.count" must be a positive number'); 343 | t.deepEqual(result.error?.details[0].path, ['nestedJsonField', 'count']); 344 | t.is(result.error?.details[0].type, 'number.positive'); 345 | 346 | // Optional 'active' field missing (should be valid) 347 | data = { 348 | ...baseData, 349 | nestedJsonField: { item: 'Test Item Optional', count: 1 } // 'active' is missing 350 | }; 351 | result = schema.validate(data, { abortEarly: false }); 352 | t.falsy(result.error, 'Valid nested JSON (optional active missing) should not have an error'); 353 | }); 354 | 355 | test.serial('Complex Schema: Array Validation', async t => { 356 | const schema = ValidationTestModel.getValidationSchema(Joi); 357 | const baseData: any = { requiredString: 'array_test' }; 358 | 359 | // stringArrayField - Valid 360 | let data: any = { ...baseData, stringArrayField: ['apple', 'banana', 'cherry'] }; 361 | let result = schema.validate(data, { abortEarly: false }); 362 | t.falsy(result.error, 'Valid string array should not have an error'); 363 | 364 | // stringArrayField - Invalid (contains number) 365 | data = { ...baseData, stringArrayField: ['apple', 123, 'cherry'] }; 366 | result = schema.validate(data, { abortEarly: false }); 367 | t.truthy(result.error, 'Error for string array containing a number'); 368 | t.is(result.error?.details[0].message, '"stringArrayField[1]" must be a string'); 369 | t.deepEqual(result.error?.details[0].path, ['stringArrayField', 1]); 370 | t.is(result.error?.details[0].type, 'string.base'); 371 | 372 | // stringArrayField - Invalid (not an array) 373 | data = { ...baseData, stringArrayField: 'not an array' }; 374 | result = schema.validate(data, { abortEarly: false }); 375 | t.truthy(result.error, 'Error for non-array type for stringArrayField'); 376 | t.is(result.error?.details[0].message, '"stringArrayField" must be an array'); 377 | t.deepEqual(result.error?.details[0].path, ['stringArrayField']); 378 | t.is(result.error?.details[0].type, 'array.base'); 379 | 380 | // numberArrayField - Valid 381 | data = { ...baseData, numberArrayField: [1, 2, 3, 4] }; 382 | result = schema.validate(data, { abortEarly: false }); 383 | t.falsy(result.error, 'Valid number array should not have an error'); 384 | 385 | // numberArrayField - Invalid (contains string) 386 | data = { ...baseData, numberArrayField: [1, 'two', 3] }; 387 | result = schema.validate(data, { abortEarly: false }); 388 | t.truthy(result.error, 'Error for number array containing a string'); 389 | t.is(result.error?.details[0].message, '"numberArrayField[1]" must be a number'); 390 | t.deepEqual(result.error?.details[0].path, ['numberArrayField', 1]); 391 | t.is(result.error?.details[0].type, 'number.base'); 392 | }); 393 | 394 | test.serial('Complex Schema: Conditional Validation (when)', async t => { 395 | const schema = ValidationTestModel.getValidationSchema(Joi); 396 | const baseData: any = { requiredString: 'conditional_test' }; 397 | 398 | // Case 1: hasExtraDetails is true, extraDetails is provided (valid) 399 | let data: any = { ...baseData, hasExtraDetails: true, extraDetails: 'These are the details.' }; 400 | let result = schema.validate(data, { abortEarly: false }); 401 | t.falsy(result.error, 'Case 1 (hasExtraDetails: true, extraDetails provided) should be valid'); 402 | 403 | // Case 2: hasExtraDetails is true, extraDetails is missing (invalid) 404 | data = { ...baseData, hasExtraDetails: true }; // extraDetails is missing 405 | result = schema.validate(data, { abortEarly: false }); 406 | t.truthy(result.error, 'Case 2 (hasExtraDetails: true, extraDetails missing) should be invalid'); 407 | t.is(result.error?.details[0].message, '"extraDetails" is required'); 408 | t.deepEqual(result.error?.details[0].path, ['extraDetails']); 409 | t.is(result.error?.details[0].type, 'any.required'); 410 | 411 | // Case 3: hasExtraDetails is false, extraDetails is not provided (valid) 412 | data = { ...baseData, hasExtraDetails: false }; 413 | result = schema.validate(data, { abortEarly: false }); 414 | t.falsy(result.error, 'Case 3 (hasExtraDetails: false, extraDetails not provided) should be valid'); 415 | 416 | // Case 4: hasExtraDetails is false, extraDetails is provided (still valid, as 'otherwise' is optional) 417 | data = { ...baseData, hasExtraDetails: false, extraDetails: 'Optional details provided anyway.' }; 418 | result = schema.validate(data, { abortEarly: false }); 419 | t.falsy(result.error, 'Case 4 (hasExtraDetails: false, extraDetails provided) should be valid'); 420 | 421 | // Case 5: hasExtraDetails is undefined (treated as false by Joi default), extraDetails not provided (valid) 422 | data = { ...baseData }; // hasExtraDetails is undefined 423 | result = schema.validate(data, { abortEarly: false }); 424 | t.falsy(result.error, 'Case 5 (hasExtraDetails: undefined, extraDetails not provided) should be valid'); 425 | }); 426 | 427 | // --- Custom Validation Tests (With Assertions) --- 428 | 429 | test.serial('Custom Validation: Custom Function', async t => { 430 | const schema = ValidationTestModel.getValidationSchema(Joi); 431 | const baseData: any = { requiredString: 'custom_func_test' }; 432 | 433 | // Valid: contains 'valid_substring' 434 | let data: any = { ...baseData, customValidatedField: 'this is a valid_substring here' }; 435 | let result = schema.validate(data, { abortEarly: false }); 436 | t.falsy(result.error, 'Valid custom field (contains substring) should not have an error'); 437 | 438 | // Invalid: does not contain 'valid_substring' 439 | data = { ...baseData, customValidatedField: 'this is not right' }; 440 | result = schema.validate(data, { abortEarly: false }); 441 | t.truthy(result.error, 'Invalid custom field (missing substring) should have an error'); 442 | t.is(result.error?.details[0].message, '"customValidatedField" must contain "valid_substring", but received "this is not right"'); 443 | t.deepEqual(result.error?.details[0].path, ['customValidatedField']); 444 | t.is(result.error?.details[0].type, 'string.customValidation'); 445 | 446 | // Optional: field not provided (should be valid) 447 | data = { ...baseData }; // customValidatedField is not provided 448 | result = schema.validate(data, { abortEarly: false }); 449 | t.falsy(result.error, 'Valid custom field (not provided) should not have an error'); 450 | }); 451 | 452 | test.serial('Custom Validation: Custom Error Messages', async t => { 453 | const schema = ValidationTestModel.getValidationSchema(Joi); 454 | const baseData: any = { requiredString: 'custom_msg_test' }; 455 | 456 | // Valid number within range 457 | let data: any = { ...baseData, fieldWithCustomMessage: 15 }; 458 | let result = schema.validate(data, { abortEarly: false }); 459 | t.falsy(result.error, 'Valid number for custom message field (15) should not have an error'); 460 | 461 | // Invalid: below min (10) 462 | data = { ...baseData, fieldWithCustomMessage: 5 }; 463 | result = schema.validate(data, { abortEarly: false }); 464 | t.truthy(result.error, 'Invalid (below min) for custom message field (5) should have an error'); 465 | t.is(result.error?.details[0].message, '"fieldWithCustomMessage" must be at least 10, pal!'); 466 | t.deepEqual(result.error?.details[0].path, ['fieldWithCustomMessage']); 467 | t.is(result.error?.details[0].type, 'number.min'); 468 | 469 | // Invalid: above max (20) 470 | data = { ...baseData, fieldWithCustomMessage: 25 }; 471 | result = schema.validate(data, { abortEarly: false }); 472 | t.truthy(result.error, 'Invalid (above max) for custom message field (25) should have an error'); 473 | t.is(result.error?.details[0].message, 'Whoa there, "fieldWithCustomMessage" cannot be more than 20.'); 474 | t.deepEqual(result.error?.details[0].path, ['fieldWithCustomMessage']); 475 | t.is(result.error?.details[0].type, 'number.max'); 476 | 477 | // Invalid: wrong type 478 | data = { ...baseData, fieldWithCustomMessage: 'not a number' }; 479 | result = schema.validate(data, { abortEarly: false }); 480 | t.truthy(result.error, 'Invalid (wrong type) for custom message field should have an error'); 481 | t.is(result.error?.details[0].message, '"fieldWithCustomMessage" needs to be a number, friend.'); 482 | t.deepEqual(result.error?.details[0].path, ['fieldWithCustomMessage']); 483 | t.is(result.error?.details[0].type, 'number.base'); 484 | 485 | // Optional: field not provided (should be valid) 486 | data = { ...baseData }; // fieldWithCustomMessage is not provided 487 | result = schema.validate(data, { abortEarly: false }); 488 | t.falsy(result.error, 'Valid custom message field (not provided) should not have an error'); 489 | }); 490 | 491 | 492 | // TODO: Add more test categories as per the plan: 493 | // - Model Validation Integration Tests (Validation on .save(), .update() etc.) 494 | // - Error Handling and Reporting Tests 495 | // - Performance Considerations (Instructions/Tests) 496 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | 3 | const NotFoundError = () => { 4 | return null 5 | } 6 | 7 | class ModelError extends Error { 8 | errors: string[]; 9 | 10 | constructor(message: string, errors: string[] = []) { 11 | super(message); 12 | this.name = 'ModelError'; 13 | this.errors = errors; 14 | } 15 | } 16 | 17 | export type SQLiteType = 18 | | 'TEXT' 19 | | 'INTEGER' 20 | | 'REAL' 21 | | 'NUMERIC' 22 | | 'BLOB' 23 | | 'BOOLEAN' 24 | | 'TIMESTAMP' 25 | | 'DATE'; 26 | 27 | /** 28 | * Describes a single column within a database table for the ORM. 29 | * This definition is used by the Model to understand data types, 30 | * map data to/from the database, and generate validation schemas (e.g., via `getValidationSchema()`). 31 | */ 32 | export type ColumnType = 33 | | 'string' // SQLite: TEXT 34 | | 'number' // SQLite: INTEGER or REAL 35 | | 'boolean' // SQLite: INTEGER (0/1) 36 | | 'jsonb' // SQLite: TEXT (JSON stringified) 37 | | 'date'; // SQLite: TEXT (ISO format) 38 | 39 | /** 40 | * Column constraint types 41 | */ 42 | export type ConstraintType = 43 | | 'PRIMARY KEY' 44 | | 'NOT NULL' 45 | | 'UNIQUE' 46 | | 'CHECK' 47 | | 'DEFAULT' 48 | | 'FOREIGN KEY'; 49 | 50 | /** 51 | * Column constraint definition 52 | */ 53 | export interface Constraint { 54 | type: ConstraintType; 55 | value?: string | number | boolean; 56 | } 57 | 58 | /** 59 | * Describes a single column within a database table for the ORM. 60 | * This definition is used by the Model to understand data types, 61 | * map data to/from the database, and generate validation schemas (e.g., via `getValidationSchema()`). 62 | */ 63 | export interface Column { 64 | /** The name of the column in the database table. */ 65 | name: string; 66 | /** 67 | * The JavaScript/TypeScript type this column should be mapped to in the application code 68 | * (e.g., 'string', 'number', 'boolean', 'Date', 'Object' for JSON). 69 | * This is typically defined using the `ColumnType` enum or a similar type definition. 70 | */ 71 | type: ColumnType; 72 | /** 73 | * The underlying SQLite data type for this column (e.g., 'TEXT', 'INTEGER', 'REAL', 'BLOB'). 74 | * This can be used by the ORM for type casting or if it assists in DDL generation. 75 | * However, DDL for specific constraints (like UNIQUE, CHECK) is generally managed 76 | * separately from this schema property when creating tables. 77 | */ 78 | sqliteType?: SQLiteType; 79 | /** 80 | * Indicates if the column is required (i.e., cannot be null). 81 | * Primarily used to generate validation rules (e.g., making a field mandatory in Joi schemas). 82 | * While this could inform a `NOT NULL` DDL property if `model-one` handles table creation, 83 | * this schema property's main effect is on application-level validation. 84 | */ 85 | required?: boolean; 86 | /** 87 | * Defines specific constraints or rules for the column, intended for ORM or application-level logic. 88 | * These are primarily used to generate validation rules (e.g., for Joi schemas via `getValidationSchema()`). 89 | * IMPORTANT: These `constraints` typically DO NOT directly translate into SQL DDL 90 | * constraints (like `UNIQUE`, `CHECK`, or `FOREIGN KEY`) automatically managed by `model-one`. 91 | * Such database-level constraints should usually be defined within the `CREATE TABLE` SQL statement. 92 | */ 93 | constraints?: Constraint[]; 94 | } 95 | 96 | type Columns = Column[] 97 | 98 | /** 99 | * Defines the structure and properties of a database table for a Model. 100 | * This configuration is the blueprint used by a Model to interact with the database, 101 | * manage data serialization/deserialization, and generate validation schemas. 102 | * It dictates how the Model interprets and handles the table's data and structure. 103 | */ 104 | export interface SchemaConfigI { 105 | /** The actual name of the database table (e.g., 'users', 'products'). */ 106 | table_name: string; 107 | /** An array of `Column` definitions describing each column in the table. */ 108 | columns: Columns; 109 | /** 110 | * A list of column names (or sets of column names for composite uniques) that should hold unique values. 111 | * This is primarily leveraged for generating application-level validation logic 112 | * (e.g., informing uniqueness checks in Joi schemas or custom validation routines within the ORM). 113 | * IMPORTANT: This `uniques` property typically DOES NOT directly create SQL `UNIQUE` constraints 114 | * on the database table through `model-one`. Database-level unique constraints should generally be 115 | * defined within the `CREATE TABLE` SQL statement. 116 | */ 117 | uniques?: string[]; // This might represent single column names or groups for composite uniqueness. 118 | /** 119 | * If true, the Model will automatically manage `created_at` and `updated_at` timestamp columns. 120 | * These columns are typically of a DATETIME or TIMESTAMP compatible type and are updated by the ORM. 121 | */ 122 | timestamps?: boolean; 123 | /** 124 | * If true, the Model will employ a soft-delete strategy, usually by managing a `deleted_at` column. 125 | * Records are marked as deleted (by setting `deleted_at`) rather than being physically removed, 126 | * allowing for potential recovery or historical tracking. 127 | */ 128 | softDeletes?: boolean; 129 | } 130 | 131 | /** 132 | * Model data interface 133 | */ 134 | interface ModelDataI { 135 | id?: string; 136 | [key: string]: any; 137 | } 138 | 139 | class Form { 140 | schema: Joi.ObjectSchema 141 | data: any 142 | 143 | constructor( 144 | schema: Joi.ObjectSchema, 145 | data: any 146 | ) { 147 | this.schema = schema 148 | this.data = data.data 149 | this.validate() 150 | } 151 | 152 | // Validate if props contains at least the declared fields 153 | validate() { 154 | const { error } = this.schema.validate(this.data) 155 | if (error !== undefined) { 156 | throw new ModelError(error.message, error.details.map(detail => detail.message)) 157 | } 158 | } 159 | } 160 | 161 | class Schema implements SchemaConfigI { 162 | table_name: string 163 | columns: Columns 164 | uniques: string[] | undefined 165 | timestamps: boolean 166 | softDeletes: boolean 167 | 168 | constructor(props: { 169 | table_name: string; 170 | columns: Columns; 171 | uniques?: string[]; 172 | timestamps?: boolean; 173 | softDeletes?: boolean; 174 | }) { 175 | this.table_name = props.table_name 176 | this.columns = props.columns 177 | this.uniques = props.uniques 178 | this.timestamps = props.timestamps ?? true // Default to true for backward compatibility 179 | this.softDeletes = props.softDeletes ?? false 180 | } 181 | } 182 | 183 | class Model { 184 | id: string | null 185 | schema: SchemaConfigI 186 | data: ModelDataI 187 | 188 | constructor(schema?: SchemaConfigI, props?: ModelDataI) { 189 | this.id = props?.id || null 190 | this.schema = schema || {} as SchemaConfigI // Ensure schema is initialized 191 | this.data = props || {} 192 | } 193 | 194 | async update(partialData: Partial, env: any): Promise { 195 | if (!this.data?.id) { 196 | throw new ModelError('Instance data is missing an ID, cannot update.'); 197 | } 198 | 199 | const updatePayload = { ...this.data, ...partialData }; 200 | const arg1 = { data: updatePayload }; 201 | 202 | const ModelCtor = this.constructor as typeof Model; 203 | const resultFromStaticUpdate = await ModelCtor.update(arg1, env) as this | null; 204 | 205 | if (resultFromStaticUpdate && resultFromStaticUpdate.data) { 206 | this.data = { ...resultFromStaticUpdate.data }; 207 | this.id = resultFromStaticUpdate.data.id || this.id; 208 | return this; 209 | } else { 210 | console.error('Instance update failed: static update did not return expected data or failed.', resultFromStaticUpdate); 211 | return null; 212 | } 213 | } 214 | 215 | /** 216 | * Deletes the current model instance from the database. 217 | * @param env - The database environment/connection object 218 | * @returns The result of the delete operation 219 | * @throws {ModelError} If the instance is missing an ID 220 | */ 221 | async delete(env: any): Promise { 222 | if (!this.data?.id) { 223 | throw new ModelError('Instance data is missing an ID, cannot delete.'); 224 | } 225 | 226 | const ModelCtor = this.constructor as typeof Model; 227 | const result = await ModelCtor.delete(this.data.id, env); 228 | 229 | // Return the result from the static delete method 230 | return result; 231 | } 232 | 233 | /** 234 | * Maps JavaScript types to SQLite types 235 | */ 236 | private static getDefaultSQLiteType(columnType: ColumnType): SQLiteType { 237 | switch (columnType) { 238 | case 'string': 239 | return 'TEXT'; 240 | case 'number': 241 | return 'REAL'; 242 | case 'boolean': 243 | return 'INTEGER'; // SQLite stores booleans as 0/1 244 | case 'jsonb': 245 | return 'TEXT'; // JSON is stored as stringified TEXT 246 | case 'date': 247 | return 'TEXT'; // Dates stored as ISO strings 248 | default: 249 | return 'TEXT'; 250 | } 251 | } 252 | 253 | /** 254 | * Processes a value based on its column type for database storage 255 | */ 256 | private static processValueForStorage(value: any, columnType: ColumnType): any { 257 | if (value === null || value === undefined) { 258 | if (columnType === 'jsonb') { 259 | return 'null'; 260 | } 261 | return null; 262 | } 263 | 264 | switch (columnType) { 265 | case 'boolean': 266 | return value ? 1 : 0; 267 | case 'jsonb': 268 | return JSON.stringify(value); 269 | case 'date': 270 | return value instanceof Date ? value.toISOString() : value; 271 | default: 272 | return value; 273 | } 274 | } 275 | 276 | /** 277 | * Processes a database value based on column type for JavaScript usage 278 | */ 279 | private static processValueFromStorage(value: any, columnType: ColumnType): any { 280 | if (value === null || value === undefined) { 281 | return null; 282 | } 283 | 284 | switch (columnType) { 285 | case 'boolean': 286 | return Boolean(value); 287 | case 'number': 288 | return Number(value); 289 | case 'jsonb': 290 | if (value === '' || value === 'null') { 291 | return null; 292 | } 293 | return typeof value === 'string' ? JSON.parse(value) : value; 294 | case 'date': 295 | return value; // Client code can convert to Date if needed 296 | default: 297 | return value; 298 | } 299 | } 300 | 301 | private static deserializeData(data: any, schema: SchemaConfigI): any { 302 | const { id }: { id: string | null } = data; 303 | 304 | if (!Boolean(id)) { 305 | const keys: string[] = ['id']; 306 | const values: any[] = [`${crypto.randomUUID()}`]; 307 | 308 | schema.columns.forEach((column) => { 309 | if (data[column.name] !== undefined) { 310 | keys.push(column.name); 311 | const processedValue = this.processValueForStorage(data[column.name], column.type); 312 | 313 | if (processedValue === null) { 314 | values.push('NULL'); 315 | } else if (typeof processedValue === 'number') { 316 | values.push(processedValue.toString()); 317 | } else if (typeof processedValue === 'string') { 318 | values.push(processedValue); 319 | } else { 320 | values.push(JSON.stringify(processedValue)); 321 | } 322 | } 323 | }); 324 | 325 | const formattedValues = values.map(v => { 326 | return v === 'NULL' ? v : `'${v}'`; 327 | }).join(", "); 328 | 329 | return { values: formattedValues, keys: keys.join(", ")}; 330 | } 331 | else { 332 | const attributes: string[] = []; 333 | 334 | schema.columns.forEach((column) => { 335 | if (column.name === 'id') return; 336 | 337 | if (Object.prototype.hasOwnProperty.call(data, column.name)) { 338 | const processedValue = this.processValueForStorage(data[column.name], column.type); 339 | 340 | if (processedValue === null) { 341 | attributes.push(`${column.name} = NULL`); 342 | } else if (typeof processedValue === 'number') { 343 | attributes.push(`${column.name} = ${processedValue}`); 344 | } else { 345 | attributes.push(`${column.name} = '${processedValue}'`); 346 | } 347 | } 348 | }); 349 | 350 | return { attributes: attributes.join(", ") }; 351 | } 352 | } 353 | 354 | private static serializeData(data: any, schema: SchemaConfigI): any { 355 | const result = { ...data }; 356 | 357 | schema.columns.forEach((column) => { 358 | if (data[column.name] !== undefined) { 359 | result[column.name] = this.processValueFromStorage(data[column.name], column.type); 360 | } else { 361 | result[column.name] = null; 362 | } 363 | }); 364 | 365 | Object.keys(result).forEach(key => { 366 | if (result[key] === undefined) { 367 | result[key] = null; 368 | } 369 | }); 370 | 371 | return result; 372 | } 373 | 374 | private static createModelInstance(data: any, schemaForInstance: SchemaConfigI): any { 375 | const instance = new this(schemaForInstance); 376 | instance.id = data.id; 377 | 378 | instance.data = {}; 379 | Object.keys(data).forEach(key => { 380 | instance.data[key] = data[key]; 381 | }); 382 | if (data.id) { 383 | instance.data.id = data.id; 384 | } 385 | 386 | return instance; 387 | } 388 | 389 | static async create({ data }: any, env: any) { 390 | const { schema } = new this(); 391 | const { keys, values } = this.deserializeData(data, schema); 392 | 393 | let query = `INSERT INTO ${schema.table_name} (${keys}`; 394 | let valuesPart = `VALUES(${values}`; 395 | 396 | if (schema.timestamps) { 397 | query += `, created_at, updated_at`; 398 | valuesPart += `, datetime('now'), datetime('now')`; 399 | } 400 | 401 | query += `) ${valuesPart}) RETURNING *;`; 402 | 403 | const { results, success} = await env.prepare(query).all(); 404 | 405 | if (success && results && results[0]) { 406 | const dbRecord = { ...results[0] }; 407 | const serializedData = this.serializeData(dbRecord, schema); 408 | return this.createModelInstance(serializedData, schema); 409 | } else { 410 | console.error('Create operation failed or returned no results.'); 411 | return null; 412 | } 413 | } 414 | 415 | static async update({ data }: any, env: any) { 416 | const { schema } = new this(); 417 | const { id } = data; 418 | 419 | if (!Boolean(id)) { 420 | console.error('Update failed: No ID present in data.'); 421 | return null; 422 | } 423 | 424 | const { attributes } = this.deserializeData(data, schema); 425 | if (!attributes || attributes.length === 0) { 426 | console.warn('Update called with no attributes to update for ID:', id); 427 | // Return the existing record as a full model instance if no actual changes are made. 428 | // 'complete' should be false (or omitted) to get a model instance. 429 | return this.findById(id, env, false); // Corrected: pass false for 'complete' 430 | } 431 | 432 | let query = `UPDATE ${schema.table_name} SET ${attributes}`; 433 | 434 | if (schema.timestamps) { 435 | query += `, updated_at = datetime('now')`; 436 | } 437 | 438 | query += ` WHERE id='${id}' RETURNING *;`; 439 | 440 | const { results, success} = await env.prepare(query).all(); 441 | 442 | if (success && results && results[0]) { 443 | const dbRecord = { ...results[0] }; 444 | const serializedData = this.serializeData(dbRecord, schema); 445 | return this.createModelInstance(serializedData, schema); 446 | } else { 447 | console.error(`Update failed for ID ${id} or record not found.`); 448 | return null; 449 | } 450 | } 451 | 452 | static async delete(id: string, env: any) { 453 | const { schema } = new this(); 454 | 455 | if (!Boolean(id)) return { message: 'ID is missing.'}; 456 | 457 | if (schema.softDeletes) { 458 | const query = `UPDATE ${schema.table_name} 459 | SET deleted_at = datetime('now') 460 | WHERE id='${id}';`; 461 | const { success } = await env.prepare(query).all(); 462 | 463 | return success ? 464 | { message: `The ID ${id} from table "${schema.table_name}" has been soft deleted.` } 465 | : { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 466 | } 467 | else { 468 | const query = `DELETE FROM ${schema.table_name} WHERE id='${id}';`; 469 | const { success } = await env.prepare(query).all(); 470 | 471 | return success ? 472 | { message: `The ID ${id} from table "${schema.table_name}" has been successfully deleted.` } 473 | : { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 474 | } 475 | } 476 | 477 | static async restore(id: string, env: any) { 478 | const { schema } = new this(); 479 | 480 | if (!Boolean(id)) return { message: 'ID is missing.'}; 481 | 482 | if (!schema.softDeletes) { 483 | return { message: `Soft deletes are not enabled for table "${schema.table_name}"` }; 484 | } 485 | 486 | const query = `UPDATE ${schema.table_name} 487 | SET deleted_at = NULL 488 | WHERE id='${id}' RETURNING *;`; 489 | const { results, success } = await env.prepare(query).all(); 490 | 491 | if (!success || !results || results.length === 0) { 492 | return { message: `The ID ${id} has not been found at table "${schema.table_name}"` }; 493 | } 494 | 495 | const dbRecord = { ...results[0] }; 496 | const serializedData = this.serializeData(dbRecord, schema); 497 | 498 | return { 499 | message: `The ID ${id} from table "${schema.table_name}" has been successfully restored.`, 500 | data: this.createModelInstance(serializedData, schema) 501 | }; 502 | } 503 | 504 | static async all(env: any, includeDeleted?: Boolean) { 505 | const { schema } = new this(); 506 | 507 | let query = `SELECT * FROM ${schema.table_name}`; 508 | 509 | if (schema.softDeletes && !includeDeleted) { 510 | query += ` WHERE deleted_at IS NULL`; 511 | } 512 | 513 | query += `;`; 514 | 515 | const { results, success} = await env.prepare(query).all(); 516 | 517 | if(!success) return []; 518 | 519 | if (results && results.length > 0) { 520 | return results.map((result: any) => { 521 | const dbRecord = { ...result }; 522 | const serializedData = this.serializeData(dbRecord, schema); 523 | return this.createModelInstance(serializedData, schema); 524 | }); 525 | } else { 526 | return []; 527 | } 528 | } 529 | 530 | static async findOne(column: string, value: string, env: any, includeDeleted?: Boolean) { 531 | const { schema } = new this(); 532 | 533 | let query = `SELECT * FROM ${schema.table_name} WHERE ${column}='${value}'`; 534 | 535 | if (schema.softDeletes && !includeDeleted) { 536 | query += ` AND deleted_at IS NULL`; 537 | } 538 | 539 | query += ` LIMIT 1;`; 540 | 541 | const { results, success} = await env.prepare(query).all(); 542 | 543 | if (!success || !results || results.length === 0) { 544 | return null; 545 | } 546 | 547 | const dbRecord = { ...results[0] }; 548 | const serializedData = this.serializeData(dbRecord, schema); 549 | return this.createModelInstance(serializedData, schema); 550 | } 551 | 552 | static async findBy(column: string, value: string, env: any, includeDeleted?: Boolean) { 553 | const { schema } = new this(); 554 | 555 | let query = `SELECT * FROM ${schema.table_name} WHERE ${column}='${value}'`; 556 | 557 | if (schema.softDeletes && !includeDeleted) { 558 | query += ` AND deleted_at IS NULL`; 559 | } 560 | 561 | query += `;`; 562 | 563 | const { results, success } = await env.prepare(query).all(); 564 | 565 | if (!success || !results) return []; 566 | 567 | return results.map((result: any) => { 568 | const dbRecord = { ...result }; 569 | const serializedData = this.serializeData(dbRecord, schema); 570 | return this.createModelInstance(serializedData, schema); 571 | }).filter((p:any) => p !== null); 572 | } 573 | 574 | static async findById(id: string, env: any, includeDeleted?: Boolean) { 575 | const { schema } = new this(); 576 | 577 | let query = `SELECT * FROM ${schema.table_name} WHERE id='${id}'`; 578 | 579 | if (schema.softDeletes && !includeDeleted) { 580 | query += ` AND deleted_at IS NULL`; 581 | } 582 | 583 | query += ` LIMIT 1;`; 584 | 585 | const { results, success} = await env.prepare(query).all(); 586 | 587 | if (!success || !results || results.length === 0) { 588 | return null; 589 | } 590 | 591 | const dbRecord = { ...results[0] }; 592 | const serializedData = this.serializeData(dbRecord, schema); 593 | return this.createModelInstance(serializedData, schema); 594 | } 595 | 596 | static async query(sql: string, env: any, params?: any[]) { 597 | const { results, success } = await env.prepare(sql, params).all(); 598 | if (!success) return { success: false, message: 'Query failed' }; 599 | return { success: true, results }; 600 | } 601 | 602 | /** 603 | * Saves the current model instance to the database. 604 | * If the instance has an ID (from `this.id` or `this.data.id`), it dispatches to the static `update()` method. 605 | * Otherwise, it dispatches to the static `create()` method. 606 | * The instance's `data` and `id` properties are updated with the result from the database operation. 607 | * @param env - The database environment/connection object. 608 | * @returns {Promise} A promise that resolves to the current instance (this) after being updated, 609 | * or null if the operation fails or returns no data. 610 | * @throws {ModelError} Can be thrown by underlying create/update operations (e.g., validation). 611 | */ 612 | async save(env: any): Promise { 613 | console.log('[Model.save] Input this.id:', this.id); 614 | console.log('[Model.save] Input this.data:', JSON.stringify(this.data)); 615 | 616 | const ModelCtor = this.constructor as typeof Model; 617 | 618 | // Ensure this.data has the most current ID if this.id is set, and this.id is primary 619 | if (this.id && (!this.data.id || this.data.id !== this.id)) { 620 | console.log('[Model.save] Syncing this.data.id from this.id.'); 621 | this.data.id = this.id; 622 | } 623 | 624 | // Prepare the payload. Assumes static create/update can handle { data: ModelDataI } 625 | const payload = { data: { ...this.data } }; // Use a shallow copy of data 626 | console.log('[Model.save] Payload for static method:', JSON.stringify(payload)); 627 | 628 | let resultInstance: Model | null = null; 629 | let operationType: 'create' | 'update' | 'none' = 'none'; 630 | 631 | if (this.data.id) { // ID exists, dispatch to static update 632 | operationType = 'update'; 633 | console.log(`[Model.save] Instance has ID ${this.data.id}. Calling static update.`); 634 | resultInstance = await ModelCtor.update(payload, env); 635 | } else { // No ID, dispatch to static create 636 | operationType = 'create'; 637 | console.log('[Model.save] Instance has no ID. Calling static create.'); 638 | // Ensure no 'id' field is accidentally sent if it's null/undefined in this.data, 639 | // as static create should generate the ID. 640 | if (payload.data.hasOwnProperty('id') && (payload.data.id === null || payload.data.id === undefined)) { 641 | console.log('[Model.save] Removing null/undefined id from create payload.'); 642 | delete payload.data.id; 643 | } 644 | resultInstance = await ModelCtor.create(payload, env); 645 | } 646 | 647 | console.log(`[Model.save] Result from static ${operationType}:`, JSON.stringify(resultInstance)); 648 | 649 | if (resultInstance && resultInstance.data) { 650 | console.log('[Model.save] Operation successful. Syncing instance data with result.'); 651 | // Sync current instance's data and id with the returned data 652 | this.data = { ...resultInstance.data }; // Update internal data 653 | this.id = resultInstance.data.id || null; // Update internal id 654 | 655 | console.log('[Model.save] Synced this.id:', this.id); 656 | console.log('[Model.save] Synced this.data:', JSON.stringify(this.data)); 657 | return this; // Return the current, updated instance 658 | } else { 659 | console.error(`[Model.save] Static ${operationType} operation failed or returned no data.`); 660 | return null; 661 | } 662 | } 663 | } 664 | 665 | export { 666 | Form, 667 | Schema, 668 | Model, 669 | NotFoundError, 670 | ModelDataI, 671 | ModelError 672 | } --------------------------------------------------------------------------------