├── .gitattributes ├── .github └── workflows │ ├── tag-release.yml │ └── test-mysql.yml ├── .gitignore ├── LICENSE ├── README.md ├── connector-mod.ts ├── deps.ts ├── design └── logo.png ├── docs └── v1.0.21-migration │ └── connectors.md ├── lib ├── connectors │ ├── connector.ts │ ├── factory.ts │ ├── mongodb-connector.ts │ ├── mysql-connector.ts │ ├── postgres-connector.ts │ └── sqlite3-connector.ts ├── data-types.ts ├── database.ts ├── helpers │ ├── fields.ts │ ├── log.ts │ └── results.ts ├── model-pivot.ts ├── model.ts ├── query-builder.ts ├── relationships.ts └── translators │ ├── basic-translator.ts │ ├── sql-translator.ts │ └── translator.ts ├── mod.ts └── tests ├── connection.ts ├── deps.ts └── units ├── Relationships └── foreignkey.test.ts ├── connectors └── mysql │ ├── connection.test.ts │ └── models.test.ts └── queries ├── sqlite ├── insert.test.ts ├── response.test.ts └── update.test.ts └── update.test.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force contributors to get the EOL this project use (LF) instead of committing again just to fix 2 | # (This is mainly for developers on Windows machine that their git is automatically change their line ending to the system ones) 3 | * text=auto eol=lf 4 | -------------------------------------------------------------------------------- /.github/workflows/tag-release.yml: -------------------------------------------------------------------------------- 1 | name: Tag Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release-on-push: 8 | runs-on: ubuntu-latest 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | steps: 12 | - name: Bump version and push release 13 | uses: rymndhng/release-on-push-action@master 14 | with: 15 | bump_version_scheme: minor 16 | -------------------------------------------------------------------------------- /.github/workflows/test-mysql.yml: -------------------------------------------------------------------------------- 1 | name: Test MySQL 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | mysql: 11 | strategy: 12 | matrix: 13 | deno: ['v1.x'] 14 | runs-on: [ubuntu-latest] 15 | services: 16 | mysql: 17 | image: mysql:5.7 18 | env: 19 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 20 | MYSQL_DATABASE: test 21 | MYSQL_ROOT_PASSWORD: password 22 | ports: 23 | - 3306 24 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=5s --health-retries=3 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - name: Verify MySQL connection from host 29 | run: | 30 | sudo apt-get install -y mysql-client 31 | mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports['3306'] }} -uroot -ppassword -e "SHOW DATABASES" 32 | 33 | - uses: denolib/setup-deno@v2 34 | with: 35 | deno-version: ${{ matrix.deno }} 36 | 37 | - name: Write new .env test file 38 | run: | 39 | echo "DB_PORT=${{ job.services.mysql.ports['3306'] }}" > ./.env 40 | echo "DB_USER=root" >> ./.env 41 | echo "DB_PASS=password" >> ./.env 42 | 43 | - name: Deno Tests 44 | run: deno test -A ./tests/units/ 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .DS_Store 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | design/*.sketch 11 | *.sqlite 12 | examples/ 13 | .deno_plugins 14 | .nova/* 15 | tsconfig.json 16 | .env 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Arnaud Dellinger 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # DenoDB 4 | 5 | **⛔️ This project is not actively maintained: expect issues, and delays in reviews** 6 | 7 | - 🗣 Supports PostgreSQL, MySQL, MariaDB, SQLite and MongoDB 8 | - 🔥 Simple, typed API 9 | - 🦕 Deno-ready 10 | - [Read the documentation](https://eveningkid.github.io/denodb-docs) 11 | 12 | ```typescript 13 | import { DataTypes, Database, Model, PostgresConnector } from 'https://deno.land/x/denodb/mod.ts'; 14 | 15 | const connection = new PostgresConnector({ 16 | host: '...', 17 | username: 'user', 18 | password: 'password', 19 | database: 'airlines', 20 | }); 21 | 22 | const db = new Database(connection); 23 | 24 | class Flight extends Model { 25 | static table = 'flights'; 26 | static timestamps = true; 27 | 28 | static fields = { 29 | id: { primaryKey: true, autoIncrement: true }, 30 | departure: DataTypes.STRING, 31 | destination: DataTypes.STRING, 32 | flightDuration: DataTypes.FLOAT, 33 | }; 34 | 35 | static defaults = { 36 | flightDuration: 2.5, 37 | }; 38 | } 39 | 40 | db.link([Flight]); 41 | 42 | await db.sync({ drop: true }); 43 | 44 | await Flight.create({ 45 | departure: 'Paris', 46 | destination: 'Tokyo', 47 | }); 48 | 49 | // or 50 | 51 | const flight = new Flight(); 52 | flight.departure = 'London'; 53 | flight.destination = 'San Francisco'; 54 | await flight.save(); 55 | 56 | await Flight.select('destination').all(); 57 | // [ { destination: "Tokyo" }, { destination: "San Francisco" } ] 58 | 59 | await Flight.where('destination', 'Tokyo').delete(); 60 | 61 | const sfFlight = await Flight.select('destination').find(2); 62 | // { destination: "San Francisco" } 63 | 64 | await Flight.count(); 65 | // 1 66 | 67 | await Flight.select('id', 'destination').orderBy('id').get(); 68 | // [ { id: "2", destination: "San Francisco" } ] 69 | 70 | await sfFlight.delete(); 71 | 72 | await db.close(); 73 | ``` 74 | 75 | ## First steps 76 | 77 | Setting up your database with DenoDB is a four-step process: 78 | 79 | - **Create a database**, using `Database` (learn more [about clients](#clients)): 80 | ```typescript 81 | const connection = new PostgresConnector({ 82 | host: '...', 83 | username: 'user', 84 | password: 'password', 85 | database: 'airlines', 86 | }); 87 | 88 | const db = new Database(connection); 89 | ``` 90 | - **Create models**, extending `Model`. `table` and `fields` are both required static attributes: 91 | 92 | ```typescript 93 | class User extends Model { 94 | static table = 'users'; 95 | 96 | static timestamps = true; 97 | 98 | static fields = { 99 | id: { 100 | primaryKey: true, 101 | autoIncrement: true, 102 | }, 103 | name: DataTypes.STRING, 104 | email: { 105 | type: DataTypes.STRING, 106 | unique: true, 107 | allowNull: false, 108 | length: 50, 109 | }, 110 | }; 111 | } 112 | ``` 113 | 114 | - **Link your models**, to add them to your database instance: 115 | ```typescript 116 | db.link([User]); 117 | ``` 118 | - Optional: **Create tables in your database**, by using `sync(...)`: 119 | ```typescript 120 | await db.sync(); 121 | ``` 122 | - **Query your models!** 123 | ```typescript 124 | await User.create({ name: 'Amelia' }); 125 | await User.all(); 126 | await User.deleteById('1'); 127 | ``` 128 | 129 | ## Migrate from previous versions 130 | - `v1.0.21`: [Migrate to connectors](docs/v1.0.21-migrations/connectors.md) 131 | 132 | ## License 133 | 134 | MIT License — [eveningkid](https://github.com/eveningkid) 135 | -------------------------------------------------------------------------------- /connector-mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Connector, 3 | ConnectorClient, 4 | ConnectorOptions, 5 | } from "./lib/connectors/"; 6 | export type { QueryDescription } from "./lib/query-builder"; 7 | export { BasicTranslator, SQLTranslator, Translator } from "./lib/translators/"; 8 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export * as ConsoleColor from "https://deno.land/x/colorlog@v1.0/mod.ts"; 2 | 3 | // NOTE: Migrate to the official https://github.com/aghussb/dex when it's updated to the 4 | // latest deno version. 5 | export { default as SQLQueryBuilder } from "https://raw.githubusercontent.com/Zhomart/dex/930253915093e1e08d48ec0409b4aee800d8bd0c/mod-dyn.ts"; 6 | 7 | export { camelCase, snakeCase } from "https://deno.land/x/case@v2.1.0/mod.ts"; 8 | 9 | export { 10 | Client as MySQLClient, 11 | configLogger as configMySQLLogger, 12 | Connection as MySQLConnection, 13 | } from "https://deno.land/x/mysql@v2.11.0/mod.ts"; 14 | export type { LoggerConfig } from "https://deno.land/x/mysql@v2.11.0/mod.ts"; 15 | 16 | export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.14.2/mod.ts"; 17 | 18 | export { DB as SQLiteClient } from "https://deno.land/x/sqlite@v3.7.0/mod.ts"; 19 | 20 | export { MongoClient as MongoDBClient, Bson } from "https://deno.land/x/mongo@v0.28.1/mod.ts"; 21 | export type { ConnectOptions as MongoDBClientOptions } from "https://deno.land/x/mongo@v0.28.1/mod.ts"; 22 | export type { Database as MongoDBDatabase } from "https://deno.land/x/mongo@v0.28.1/src/database.ts"; 23 | -------------------------------------------------------------------------------- /design/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eveningkid/denodb/741f1dd89c28f0ed7944a208f76590aeb213ab33/design/logo.png -------------------------------------------------------------------------------- /docs/v1.0.21-migration/connectors.md: -------------------------------------------------------------------------------- 1 | # Migration Guide - Dialect to connector 2 | 3 | > The Issue that suggested that [#121](https://github.com/eveningkid/denodb/issues/121) 4 | > 5 | > The PR with change [#126](https://github.com/eveningkid/denodb/pull/126) 6 | 7 | ## Why we change 8 | 9 | We switched to this behavior, so you'll be able to use databases that we don't support out of the box (such as _redshift_). 10 | 11 | ## How to migrate 12 | 13 | We wanted that the migration for the new behavior to be painless and familiar with the previous behavior. 14 | 15 | Let's assume you have the following: 16 | 17 | ```typescript 18 | import { Database } from 'https://deno.land/x/denodb/mod.ts'; 19 | 20 | const db = new Database('postgres', { 21 | host: '...', 22 | username: 'user', 23 | password: 'password', 24 | database: 'airlines', 25 | }); 26 | ``` 27 | 28 | To migrate, you only need to **replace the dialect (in this example is `'postgres'`) with its connector (`PostgresConnector`) and pass the options to the connector**: 29 | 30 | ```typescript 31 | import { Database, PostgresConnector } from 'https://deno.land/x/denodb/mod.ts'; 32 | 33 | const connector = new PostgresConnector({ 34 | host: '...', 35 | username: 'user', 36 | password: 'password', 37 | database: 'airlines', 38 | }); 39 | 40 | const db = new Database(connector); 41 | ``` 42 | 43 | See! It's that easy to migrate. 44 | 45 | If you need the `debug` option to be on, here is what you had before: 46 | 47 | ```typescript 48 | import { Database } from 'https://deno.land/x/denodb/mod.ts'; 49 | 50 | const db = new Database( 51 | { dialect: 'postgres', debug: true }, 52 | { 53 | host: '...', 54 | username: 'user', 55 | password: 'password', 56 | database: 'airlines', 57 | } 58 | ); 59 | ``` 60 | 61 | Let's do what we just did for the connector before and add the debug flag: 62 | 63 | ```typescript 64 | import { Database, PostgresConnector } from 'https://deno.land/x/denodb/mod.ts'; 65 | 66 | const connector = new PostgresConnector({ 67 | host: '...', 68 | username: 'user', 69 | password: 'password', 70 | database: 'airlines', 71 | }); 72 | 73 | const db = new Database({ 74 | connector, 75 | debug: true, // <- 76 | }); 77 | ``` 78 | 79 | And what if you get the database type from an environment variable or somewhere else? You can still keep it similar to what we had before: 80 | 81 | ```typescript 82 | import { Database } from 'https://deno.land/x/denodb/mod.ts'; 83 | 84 | const db = new Database.forDialect('postgres', { 85 | host: '...', 86 | username: 'user', 87 | password: 'password', 88 | database: 'airlines', 89 | }); 90 | 91 | 92 | // If you need to debug, replace 'postgres' with: 93 | const db = new Database.forDialect({ dialect: 'postgres', debug: true }, { 94 | ... 95 | }); 96 | ``` 97 | 98 | ## Disable the warning (not recommended) 99 | 100 | You probably came here because you got the following: 101 | 102 | ``` 103 | [denodb]: DEPRECATION warning, the usage with dialect instead of connector is deprecated and will be removed in future versions. 104 | [denodb]: If you want to disable this warning pass `disableDialectUsageDeprecationWarning: true` with the dialect in the Database constructor. 105 | [denodb]: If you want to migrate to the current behavior, visit https://github.com/eveningkid/denodb/blob/master/docs/v1.0.21-migrations/connectors.md for help. 106 | ``` 107 | 108 | If you want to disable this warning and continue with the dialect behavior (not recommended), you can pass `disableDialectUsageDeprecationWarning: true` in the dialect options: 109 | 110 | ```typescript 111 | import { Database } from 'https://deno.land/x/denodb/mod.ts'; 112 | 113 | const db = new Database( 114 | { 115 | dialect: 'postgres', 116 | disableDialectUsageDeprecationWarning: true, 117 | }, 118 | { 119 | host: '...', 120 | username: 'user', 121 | password: 'password', 122 | database: 'airlines', 123 | } 124 | ); 125 | ``` 126 | -------------------------------------------------------------------------------- /lib/connectors/connector.ts: -------------------------------------------------------------------------------- 1 | import type { QueryDescription } from "../query-builder.ts"; 2 | import { Translator } from "../translators/translator.ts"; 3 | 4 | /** Default connector options. */ 5 | export interface ConnectorOptions {} 6 | 7 | /** Default connector client. */ 8 | export interface ConnectorClient {} 9 | 10 | /** Connector interface for a database provider connection. */ 11 | export interface Connector { 12 | /** Database dialect this connector is for. */ 13 | readonly _dialect: string; 14 | 15 | /** Translator that converts queries to a database-specific command. */ 16 | _translator: Translator; 17 | 18 | /** Client that maintains an external database connection. */ 19 | _client: ConnectorClient; 20 | 21 | /** Options to connect to an external instance. */ 22 | _options: ConnectorOptions; 23 | 24 | /** Is the client connected to an external instance. */ 25 | _connected: boolean; 26 | 27 | /** Test connection. */ 28 | ping(): Promise; 29 | 30 | /** Connect to an external database instance. */ 31 | _makeConnection(): void; 32 | 33 | /** Execute a query on the external database instance. */ 34 | query(queryDescription: QueryDescription): Promise; 35 | 36 | /** Execute queries within a transaction on the database instance. */ 37 | transaction?(queries: () => Promise): Promise; 38 | 39 | /** Disconnect from the external database instance. */ 40 | close(): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /lib/connectors/factory.ts: -------------------------------------------------------------------------------- 1 | import { MongoDBConnector, MongoDBOptions } from "./mongodb-connector.ts"; 2 | import { SQLite3Connector, SQLite3Options } from "./sqlite3-connector.ts"; 3 | import { MySQLConnector, MySQLOptions } from "./mysql-connector.ts"; 4 | import { PostgresConnector, PostgresOptions } from "./postgres-connector.ts"; 5 | import type { BuiltInDatabaseDialect } from "../database.ts"; 6 | import { Connector, ConnectorOptions } from "./connector.ts"; 7 | 8 | export function connectorFactory( 9 | dialect: BuiltInDatabaseDialect, 10 | connectionOptions: ConnectorOptions, 11 | ): Connector { 12 | switch (dialect) { 13 | case "mongo": 14 | return new MongoDBConnector(connectionOptions as MongoDBOptions); 15 | case "sqlite3": 16 | return new SQLite3Connector(connectionOptions as SQLite3Options); 17 | case "mysql": 18 | return new MySQLConnector(connectionOptions as MySQLOptions); 19 | case "postgres": 20 | return new PostgresConnector(connectionOptions as PostgresOptions); 21 | default: 22 | throw new Error( 23 | `No connector was found for the given dialect: ${dialect}.`, 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/connectors/mongodb-connector.ts: -------------------------------------------------------------------------------- 1 | import { MongoDBClient, Bson } from "../../deps.ts"; 2 | import type { MongoDBClientOptions, MongoDBDatabase } from "../../deps.ts"; 3 | import type { Connector, ConnectorOptions } from "./connector.ts"; 4 | import type { QueryDescription } from "../query-builder.ts"; 5 | import { BasicTranslator } from "../translators/basic-translator.ts"; 6 | 7 | type MongoDBOptionsBase = { 8 | database: string; 9 | }; 10 | 11 | type MongoDBOptionsWithURI = { 12 | uri: string; 13 | }; 14 | 15 | export type MongoDBOptions = 16 | & ConnectorOptions 17 | & (MongoDBOptionsWithURI | MongoDBClientOptions) 18 | & MongoDBOptionsBase; 19 | 20 | export class MongoDBConnector implements Connector { 21 | _dialect = "mongo"; 22 | 23 | _translator: BasicTranslator; 24 | _client: MongoDBClient; 25 | _database?: MongoDBDatabase; 26 | _options: MongoDBOptions; 27 | _connected = false; 28 | 29 | /** Create a MongoDB connection. */ 30 | constructor(options: MongoDBOptions) { 31 | this._options = options; 32 | this._client = new MongoDBClient(); 33 | this._translator = new BasicTranslator(); 34 | } 35 | 36 | async _makeConnection() { 37 | if (this._connected) { 38 | return; 39 | } 40 | 41 | if (this._options.hasOwnProperty("uri")) { 42 | await this._client.connect((this._options as MongoDBOptionsWithURI).uri); 43 | } else { 44 | await this._client.connect(this._options as MongoDBClientOptions); 45 | } 46 | 47 | this._database = this._client.database(this._options.database); 48 | this._connected = true; 49 | } 50 | 51 | async ping() { 52 | await this._makeConnection(); 53 | 54 | try { 55 | const databases = await this._client.listDatabases(); 56 | return databases.map((database) => database.name).includes( 57 | this._options.database, 58 | ); 59 | } catch (error) { 60 | return false; 61 | } 62 | } 63 | 64 | async query(queryDescription: QueryDescription): Promise { 65 | await this._makeConnection(); 66 | 67 | if (queryDescription.type === "create") { 68 | // There is no need to initialize collections in MongoDB 69 | return []; 70 | } 71 | 72 | const collection = this._database!.collection(queryDescription.table!); 73 | 74 | let wheres: { [k: string]: any } = {}; 75 | if (queryDescription.wheres) { 76 | for (const whereClause of queryDescription.wheres) { 77 | if (whereClause.field === "_id") { 78 | whereClause.value = new Bson.ObjectId(whereClause.value); 79 | } 80 | } 81 | 82 | wheres = queryDescription.wheres.reduce((prev, curr) => { 83 | let mongoOperator = "$eq"; 84 | 85 | switch (curr.operator) { 86 | case "<": 87 | mongoOperator = "$lt"; 88 | break; 89 | 90 | case "<=": 91 | mongoOperator = "$lte"; 92 | break; 93 | 94 | case ">": 95 | mongoOperator = "$gt"; 96 | break; 97 | 98 | case ">=": 99 | mongoOperator = "$gte"; 100 | break; 101 | } 102 | 103 | return { 104 | ...prev, 105 | [curr.field]: { 106 | [mongoOperator]: curr.value, 107 | }, 108 | }; 109 | }, {}); 110 | } 111 | 112 | let results: any[] = []; 113 | 114 | switch (queryDescription.type) { 115 | case "drop": 116 | await collection.deleteMany({}); 117 | break; 118 | 119 | case "insert": 120 | const defaultedValues = queryDescription.schema.defaults; 121 | let values = Array.isArray(queryDescription.values) 122 | ? queryDescription.values! 123 | : [queryDescription.values!]; 124 | 125 | values = values.map((record) => { 126 | let timestamps = {}; 127 | 128 | if (queryDescription.schema.timestamps) { 129 | timestamps = { 130 | createdAt: new Date(), 131 | updatedAt: new Date(), 132 | }; 133 | } 134 | 135 | return { ...defaultedValues, ...record, ...timestamps }; 136 | }); 137 | 138 | const insertedRecords = await collection.insertMany( 139 | values, 140 | ); 141 | 142 | const recordIds = insertedRecords.insertedIds as unknown as string[]; 143 | return await queryDescription.schema.find(recordIds); 144 | 145 | case "select": 146 | const selectFields: Object[] = []; 147 | 148 | if (queryDescription.whereIn) { 149 | 150 | if (queryDescription.whereIn.field === "_id") { 151 | queryDescription.whereIn.possibleValues = queryDescription.whereIn.possibleValues.map( 152 | (value) => new Bson.ObjectId(value) 153 | ); 154 | } 155 | 156 | wheres[queryDescription.whereIn.field] = { 157 | $in: queryDescription.whereIn.possibleValues, 158 | }; 159 | } 160 | 161 | selectFields.push({ 162 | $match: wheres, 163 | }); 164 | 165 | if (queryDescription.select) { 166 | selectFields.push({ 167 | $project: queryDescription.select.reduce((prev: Object, curr) => { 168 | if (typeof curr === "string") { 169 | return { 170 | ...prev, 171 | [curr]: 1, 172 | }; 173 | } else { 174 | const [field, alias] = Object.entries(curr)[0]; 175 | return { 176 | ...prev, 177 | [alias]: field, 178 | }; 179 | } 180 | }, {}), 181 | }); 182 | } 183 | 184 | if (queryDescription.joins) { 185 | const join = queryDescription.joins[0]; 186 | selectFields.push({ 187 | $lookup: { 188 | from: join.joinTable, 189 | localField: join.originField, 190 | foreignField: "_id", 191 | as: join.targetField, 192 | }, 193 | }); 194 | } 195 | 196 | if (queryDescription.orderBy) { 197 | selectFields.push({ 198 | $sort: Object.entries(queryDescription.orderBy).reduce( 199 | (prev: any, [field, orderDirection]) => { 200 | prev[field] = orderDirection === "asc" ? 1 : -1; 201 | return prev; 202 | }, 203 | {}, 204 | ), 205 | }); 206 | } 207 | 208 | if (queryDescription.groupBy) { 209 | selectFields.push({ 210 | $group: { 211 | _id: `$${queryDescription.groupBy}`, 212 | }, 213 | }); 214 | } 215 | 216 | if (queryDescription.limit) { 217 | selectFields.push({ $limit: queryDescription.limit }); 218 | } 219 | 220 | if (queryDescription.offset) { 221 | selectFields.push({ $skip: queryDescription.offset }); 222 | } 223 | 224 | results = await collection.aggregate(selectFields).toArray(); 225 | break; 226 | 227 | case "update": 228 | await collection.updateMany(wheres, { $set: queryDescription.values! }); 229 | break; 230 | 231 | case "delete": 232 | await collection.deleteMany(wheres); 233 | break; 234 | 235 | case "count": 236 | return [{ count: await collection.count(wheres) }]; 237 | 238 | case "avg": 239 | return await collection.aggregate([ 240 | { $match: wheres }, 241 | { 242 | $group: { 243 | _id: null, 244 | avg: { $avg: `$${queryDescription.aggregatorField}` }, 245 | }, 246 | }, 247 | ]).toArray(); 248 | 249 | case "max": 250 | return await collection.aggregate([ 251 | { $match: wheres }, 252 | { 253 | $group: { 254 | _id: null, 255 | max: { $max: `$${queryDescription.aggregatorField}` }, 256 | }, 257 | }, 258 | ]).toArray(); 259 | 260 | case "min": 261 | return await collection.aggregate([ 262 | { $match: wheres }, 263 | { 264 | $group: { 265 | _id: null, 266 | min: { $min: `$${queryDescription.aggregatorField}` }, 267 | }, 268 | }, 269 | ]).toArray(); 270 | 271 | case "sum": 272 | return await collection.aggregate([ 273 | { $match: wheres }, 274 | { 275 | $group: { 276 | _id: null, 277 | sum: { $sum: `$${queryDescription.aggregatorField}` }, 278 | }, 279 | }, 280 | ]).toArray(); 281 | 282 | default: 283 | throw new Error(`Unknown query type: ${queryDescription.type}.`); 284 | } 285 | 286 | results = results.map((result) => { 287 | const formattedResult: { [k: string]: any } = {}; 288 | 289 | for (const [field, value] of Object.entries(result)) { 290 | if (field === "_id") { 291 | formattedResult._id = (value as { $oid?: string })?.$oid || value; 292 | } else if ((value as { $date?: { $numberLong: number } }).$date) { 293 | formattedResult[field] = new Date((value as any).$date.$numberLong); 294 | } else { 295 | formattedResult[field] = value; 296 | } 297 | } 298 | 299 | return formattedResult; 300 | }); 301 | 302 | return results; 303 | } 304 | 305 | close() { 306 | if (!this._connected) { 307 | return Promise.resolve(); 308 | } 309 | 310 | this._client.close(); 311 | this._connected = false; 312 | return Promise.resolve(); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /lib/connectors/mysql-connector.ts: -------------------------------------------------------------------------------- 1 | import { configMySQLLogger, MySQLClient, MySQLConnection } from "../../deps.ts"; 2 | import type { LoggerConfig } from "../../deps.ts"; 3 | import type { Connector, ConnectorOptions } from "./connector.ts"; 4 | import { SQLTranslator } from "../translators/sql-translator.ts"; 5 | import type { SupportedSQLDatabaseDialect } from "../translators/sql-translator.ts"; 6 | import type { QueryDescription } from "../query-builder.ts"; 7 | 8 | export interface MySQLOptions extends ConnectorOptions { 9 | database: string; 10 | host: string; 11 | username: string; 12 | password: string; 13 | port?: number; 14 | charset?: string; 15 | logger?: LoggerConfig; 16 | } 17 | 18 | export class MySQLConnector implements Connector { 19 | _dialect: SupportedSQLDatabaseDialect = "mysql"; 20 | 21 | _client: MySQLClient; 22 | _options: MySQLOptions; 23 | _translator: SQLTranslator; 24 | _connected = false; 25 | 26 | /** Create a MySQL connection. */ 27 | constructor(options: MySQLOptions) { 28 | this._options = options; 29 | this._client = new MySQLClient(); 30 | this._translator = new SQLTranslator(this._dialect); 31 | } 32 | 33 | async _makeConnection() { 34 | if (this._connected) { 35 | return; 36 | } 37 | 38 | if (this._options.logger !== undefined) { 39 | await configMySQLLogger(this._options.logger); 40 | } 41 | 42 | await this._client.connect({ 43 | hostname: this._options.host, 44 | username: this._options.username, 45 | db: this._options.database, 46 | password: this._options.password, 47 | port: this._options.port ?? 3306, 48 | charset: this._options.charset ?? "utf8", 49 | }); 50 | 51 | this._connected = true; 52 | } 53 | 54 | async ping() { 55 | await this._makeConnection(); 56 | 57 | try { 58 | const [{ result }] = await this._client.query("SELECT 1 + 1 as result"); 59 | return result === 2; 60 | } catch { 61 | return false; 62 | } 63 | } 64 | 65 | async query( 66 | queryDescription: QueryDescription, 67 | client?: MySQLClient | MySQLConnection, 68 | // deno-lint-ignore no-explicit-any 69 | ): Promise { 70 | await this._makeConnection(); 71 | 72 | const queryClient = client ?? this._client; 73 | const query = this._translator.translateToQuery(queryDescription); 74 | const subqueries = query.split(/;(?=(?:[^'"]|'[^']*'|"[^"]*")*$)/); 75 | const queryMethod = query.toLowerCase().startsWith("select") 76 | ? "query" 77 | : "execute"; 78 | 79 | for (let i = 0; i < subqueries.length; i++) { 80 | const result = await queryClient[queryMethod](subqueries[i]); 81 | 82 | if (i === subqueries.length - 1) { 83 | return result; 84 | } 85 | } 86 | } 87 | 88 | transaction(queries: () => Promise) { 89 | return this._client.transaction(queries); 90 | } 91 | 92 | async close() { 93 | if (!this._connected) { 94 | return; 95 | } 96 | 97 | await this._client.close(); 98 | this._connected = false; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/connectors/postgres-connector.ts: -------------------------------------------------------------------------------- 1 | import { PostgresClient } from "../../deps.ts"; 2 | import type { Connector, ConnectorOptions } from "./connector.ts"; 3 | import { SQLTranslator } from "../translators/sql-translator.ts"; 4 | import type { SupportedSQLDatabaseDialect } from "../translators/sql-translator.ts"; 5 | import type { QueryDescription } from "../query-builder.ts"; 6 | import type { Values } from "../data-types.ts"; 7 | 8 | interface PostgresOptionsWithConfig extends ConnectorOptions { 9 | database: string; 10 | host: string; 11 | username: string; 12 | password: string; 13 | port?: number; 14 | } 15 | 16 | interface PostgresOptionsWithURI extends ConnectorOptions { 17 | uri: string; 18 | } 19 | 20 | export type PostgresOptions = 21 | | PostgresOptionsWithConfig 22 | | PostgresOptionsWithURI; 23 | 24 | export class PostgresConnector implements Connector { 25 | _dialect: SupportedSQLDatabaseDialect = "postgres"; 26 | 27 | _client: PostgresClient; 28 | _options: PostgresOptions; 29 | _translator: SQLTranslator; 30 | _connected = false; 31 | 32 | /** Create a PostgreSQL connection. */ 33 | constructor(options: PostgresOptions) { 34 | this._options = options; 35 | if ("uri" in options) { 36 | this._client = new PostgresClient(options.uri); 37 | } else { 38 | this._client = new PostgresClient({ 39 | hostname: options.host, 40 | user: options.username, 41 | password: options.password, 42 | database: options.database, 43 | port: options.port ?? 5432, 44 | }); 45 | } 46 | this._translator = new SQLTranslator(this._dialect); 47 | } 48 | 49 | async _makeConnection() { 50 | if (this._connected) { 51 | return; 52 | } 53 | 54 | await this._client.connect(); 55 | this._connected = true; 56 | } 57 | 58 | async ping() { 59 | await this._makeConnection(); 60 | 61 | try { 62 | const [result] = ( 63 | await this._client.queryObject("SELECT 1 + 1 as result") 64 | ).rows; 65 | return result === 2; 66 | } catch { 67 | return false; 68 | } 69 | } 70 | 71 | // deno-lint-ignore no-explicit-any 72 | async query(queryDescription: QueryDescription): Promise { 73 | await this._makeConnection(); 74 | 75 | const query = this._translator.translateToQuery(queryDescription); 76 | const response = await this._client.queryObject(query); 77 | const results = response.rows as Values[]; 78 | 79 | if (queryDescription.type === "insert") { 80 | return results.length === 1 ? results[0] : results; 81 | } 82 | 83 | return results; 84 | } 85 | 86 | async transaction(queries: () => Promise) { 87 | const transaction = this._client.createTransaction("transaction"); 88 | await transaction.begin(); 89 | await queries(); 90 | return transaction.commit(); 91 | } 92 | 93 | async close() { 94 | if (!this._connected) { 95 | return; 96 | } 97 | 98 | await this._client.end(); 99 | this._connected = false; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/connectors/sqlite3-connector.ts: -------------------------------------------------------------------------------- 1 | import { SQLiteClient } from "../../deps.ts"; 2 | import type { Connector, ConnectorOptions } from "./connector.ts"; 3 | import type { QueryDescription } from "../query-builder.ts"; 4 | import type { FieldValue } from "../data-types.ts"; 5 | import { SQLTranslator } from "../translators/sql-translator.ts"; 6 | import type { SupportedSQLDatabaseDialect } from "../translators/sql-translator.ts"; 7 | 8 | export interface SQLite3Options extends ConnectorOptions { 9 | filepath: string; 10 | } 11 | 12 | export class SQLite3Connector implements Connector { 13 | _dialect: SupportedSQLDatabaseDialect = "sqlite3"; 14 | 15 | _client: SQLiteClient; 16 | _options: SQLite3Options; 17 | _translator: SQLTranslator; 18 | _connected = false; 19 | 20 | /** Create a SQLite connection. */ 21 | constructor(options: SQLite3Options) { 22 | this._options = options; 23 | this._client = new SQLiteClient(this._options.filepath); 24 | this._translator = new SQLTranslator(this._dialect); 25 | } 26 | 27 | _makeConnection() { 28 | if (this._connected) { 29 | return; 30 | } 31 | 32 | this._connected = true; 33 | } 34 | 35 | ping() { 36 | this._makeConnection(); 37 | 38 | try { 39 | let connected = false; 40 | 41 | for (const [result] of this._client.query("SELECT 1 + 1")) { 42 | connected = result === 2; 43 | } 44 | 45 | return Promise.resolve(connected); 46 | } catch { 47 | return Promise.resolve(false); 48 | } 49 | } 50 | 51 | // deno-lint-ignore no-explicit-any 52 | query(queryDescription: QueryDescription): Promise { 53 | this._makeConnection(); 54 | 55 | const query = this._translator.translateToQuery(queryDescription); 56 | const subqueries = query.split(/;(?=(?:[^'"]|'[^']*'|"[^"]*")*$)/); 57 | 58 | const results = subqueries.map((subquery, index) => { 59 | const preparedQuery = this._client.prepareQuery(subquery + ";"); 60 | const response = preparedQuery.allEntries(); 61 | preparedQuery.finalize(); 62 | 63 | if (index < subqueries.length - 1) { 64 | return []; 65 | } 66 | 67 | if (response.length === 0) { 68 | if (queryDescription.type === "insert" && queryDescription.values) { 69 | return { 70 | affectedRows: this._client.changes, 71 | lastInsertId: this._client.lastInsertRowId, 72 | }; 73 | } 74 | 75 | if (queryDescription.type === "select") { 76 | return []; 77 | } 78 | 79 | return { affectedRows: this._client.changes }; 80 | } 81 | 82 | return response.map(row => { 83 | const result: Record = {}; 84 | for (const [columnName, value] of Object.entries(row)) { 85 | if (columnName === "count(*)") { 86 | result.count = value as FieldValue; 87 | } else if (columnName.startsWith("max(")) { 88 | result.max = value as FieldValue; 89 | } else if (columnName.startsWith("min(")) { 90 | result.min = value as FieldValue; 91 | } else if (columnName.startsWith("sum(")) { 92 | result.sum = value as FieldValue; 93 | } else if (columnName.startsWith("avg(")) { 94 | result.avg = value as FieldValue; 95 | } else { 96 | result[columnName] = value as FieldValue; 97 | } 98 | } 99 | return result; 100 | }); 101 | }); 102 | 103 | return Promise.resolve(results[results.length - 1]); 104 | } 105 | 106 | async transaction(queries: () => Promise) { 107 | this._client.query("begin"); 108 | 109 | try { 110 | await queries(); 111 | this._client.query("commit"); 112 | } catch (error) { 113 | this._client.query("rollback"); 114 | throw error; 115 | } 116 | } 117 | 118 | close() { 119 | if (!this._connected) { 120 | return Promise.resolve(); 121 | } 122 | 123 | this._client.close(); 124 | this._connected = false; 125 | return Promise.resolve(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/data-types.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSchema } from "./model.ts"; 2 | import { Bson } from "../deps.ts"; 3 | 4 | type ObjectId = Bson.ObjectId; 5 | 6 | /** Field Types. */ 7 | export type FieldTypeString = 8 | | "bigInteger" 9 | | "integer" 10 | | "decimal" 11 | | "float" 12 | | "uuid" 13 | | "boolean" 14 | | "binary" 15 | | "enu" 16 | | "string" 17 | | "text" 18 | | "date" 19 | | "datetime" 20 | | "time" 21 | | "timestamp" 22 | | "json" 23 | | "jsonb"; 24 | 25 | export type FieldTypes = 26 | | "BIG_INTEGER" 27 | | "INTEGER" 28 | | "DECIMAL" 29 | | "FLOAT" 30 | | "UUID" 31 | | "BOOLEAN" 32 | | "BINARY" 33 | | "ENUM" 34 | | "STRING" 35 | | "TEXT" 36 | | "DATE" 37 | | "DATETIME" 38 | | "TIME" 39 | | "TIMESTAMP" 40 | | "JSON" 41 | | "JSONB"; 42 | 43 | export type Fields = 44 | & { 45 | [key in FieldTypes]: FieldTypeString; 46 | } 47 | & { 48 | decimal: (precision: number, scale?: number) => { 49 | type: FieldTypeString; 50 | precision: number; 51 | scale?: number; 52 | }; 53 | string: (length: number) => { type: FieldTypeString; length: number }; 54 | enum: ( 55 | values: (number | string)[], 56 | ) => { type: FieldTypeString; values: (number | string)[] }; 57 | integer: (length: number) => { type: FieldTypeString; length: number }; 58 | }; 59 | 60 | export type FieldProps = { 61 | type?: FieldTypeString; 62 | as?: string; 63 | primaryKey?: boolean; 64 | unique?: boolean; 65 | autoIncrement?: boolean; 66 | length?: number; 67 | allowNull?: boolean; 68 | precision?: number; 69 | scale?: number; 70 | values?: (number | string)[]; 71 | relationship?: Relationship; 72 | comment?: string; 73 | }; 74 | 75 | export type FieldType = FieldTypeString | FieldProps; 76 | 77 | export type FieldAlias = { [k: string]: string }; 78 | export type FieldValue = number | string | boolean | Date | ObjectId | null ; 79 | export type FieldOptions = { 80 | name: string; 81 | type: FieldType; 82 | defaultValue: FieldValue | (() => FieldValue); 83 | }; 84 | 85 | export type Values = { [key: string]: FieldValue }; 86 | 87 | /** Relationship Types. */ 88 | export type Relationship = { 89 | kind: "single" | "multiple"; 90 | model: ModelSchema; 91 | }; 92 | 93 | export type RelationshipType = { 94 | type: FieldTypeString; 95 | relationship: Relationship; 96 | }; 97 | 98 | /** Available fields data types. */ 99 | export const DATA_TYPES: Fields = { 100 | INTEGER: "integer", 101 | BIG_INTEGER: "bigInteger", 102 | DECIMAL: "decimal", 103 | FLOAT: "float", 104 | UUID: "uuid", 105 | 106 | BOOLEAN: "boolean", 107 | BINARY: "binary", 108 | 109 | ENUM: "enu", 110 | STRING: "string", 111 | TEXT: "text", 112 | 113 | DATE: "date", 114 | DATETIME: "datetime", 115 | TIME: "time", 116 | TIMESTAMP: "timestamp", 117 | 118 | JSON: "json", 119 | JSONB: "jsonb", 120 | 121 | decimal(precision: number, scale?: number) { 122 | return { 123 | type: this.DECIMAL, 124 | precision, 125 | scale, 126 | }; 127 | }, 128 | 129 | string(length: number) { 130 | return { 131 | type: this.STRING, 132 | length, 133 | }; 134 | }, 135 | 136 | enum(values: (number | string)[]) { 137 | return { 138 | type: this.ENUM, 139 | values, 140 | }; 141 | }, 142 | 143 | integer(length: number) { 144 | return { 145 | type: this.INTEGER, 146 | length, 147 | }; 148 | }, 149 | }; 150 | 151 | export const DataTypes = DATA_TYPES; 152 | -------------------------------------------------------------------------------- /lib/database.ts: -------------------------------------------------------------------------------- 1 | import type { Connector, ConnectorOptions } from "./connectors/connector.ts"; 2 | import type { 3 | FieldMatchingTable, 4 | Model, 5 | ModelFields, 6 | ModelSchema, 7 | } from "./model.ts"; 8 | import { QueryBuilder, QueryDescription } from "./query-builder.ts"; 9 | import { formatResultToModelInstance } from "./helpers/results.ts"; 10 | import { Translator } from "./translators/translator.ts"; 11 | import { connectorFactory } from "./connectors/factory.ts"; 12 | import { warning } from "./helpers/log.ts"; 13 | 14 | export type BuiltInDatabaseDialect = "postgres" | "sqlite3" | "mysql" | "mongo"; 15 | 16 | type ConnectorDatabaseOptions = { 17 | connector: Connector; 18 | debug?: boolean; 19 | }; 20 | 21 | type DialectDatabaseOptions = 22 | | BuiltInDatabaseDialect 23 | | { 24 | dialect: BuiltInDatabaseDialect; 25 | debug?: boolean; 26 | disableDialectUsageDeprecationWarning?: boolean; 27 | }; 28 | 29 | export type DatabaseOptionsOrConnector = 30 | | Connector 31 | | ConnectorDatabaseOptions; 32 | 33 | export type DatabaseOptions = 34 | | DatabaseOptionsOrConnector 35 | | DialectDatabaseOptions; 36 | 37 | export type SyncOptions = { 38 | /** If tables should be dropped if they exist. */ 39 | drop?: boolean; 40 | /** If tables should be truncated. Will raise errors if the linked tables don't exist. */ 41 | truncate?: boolean; 42 | }; 43 | 44 | /** Database client which interacts with an external database instance. */ 45 | export class Database { 46 | private _connector: Connector; 47 | private _translator: Translator; 48 | private _queryBuilder: QueryBuilder; 49 | private _models: ModelSchema[] = []; 50 | private _debug: boolean; 51 | 52 | /** Initialize database given a dialect and options. 53 | * 54 | * Current Usage: 55 | * const db = new Database(new SQLite3Connector({ 56 | * filepath: "./db.sqlite" 57 | * })); 58 | * 59 | * const db = new Database({ 60 | * connector: new SQLite3Connector({ ... }), 61 | * debug: true 62 | * }); 63 | * 64 | * Dialect usage: 65 | * const db = new Database("sqlite3", { 66 | * filepath: "./db.sqlite" 67 | * }); 68 | * 69 | * const db = new Database({ 70 | * dialect: "sqlite3", 71 | * debug: true 72 | * }, { ... }); 73 | */ 74 | constructor( 75 | dialectOptionsOrDatabaseOptionsOrConnector: DatabaseOptions, 76 | connectionOptions?: ConnectorOptions, 77 | ) { 78 | if (Database._isInDialectForm(dialectOptionsOrDatabaseOptionsOrConnector)) { 79 | dialectOptionsOrDatabaseOptionsOrConnector = Database 80 | ._convertDialectFormToConnectorForm( 81 | dialectOptionsOrDatabaseOptionsOrConnector as DialectDatabaseOptions, 82 | connectionOptions as ConnectorOptions, 83 | ); 84 | } 85 | 86 | this._connector = 87 | (dialectOptionsOrDatabaseOptionsOrConnector as ConnectorDatabaseOptions) 88 | ?.connector ?? 89 | dialectOptionsOrDatabaseOptionsOrConnector; 90 | 91 | if (!this._connector) { 92 | throw new Error(`A connector must be defined, got ${this._connector}.`); 93 | } 94 | 95 | this._debug = 96 | (dialectOptionsOrDatabaseOptionsOrConnector as ConnectorDatabaseOptions) 97 | ?.debug ?? 98 | false; 99 | 100 | this._translator = this._connector._translator; 101 | 102 | if (!this._translator) { 103 | throw new Error( 104 | `A connector must provide a translator, got ${this._translator}.`, 105 | ); 106 | } 107 | 108 | this._queryBuilder = new QueryBuilder(); 109 | } 110 | 111 | private static _isInDialectForm( 112 | dialectOptionsOrDatabaseOptions: DatabaseOptions, 113 | ): boolean { 114 | return ( 115 | // Has dialect as a property 116 | typeof dialectOptionsOrDatabaseOptions === "object" && 117 | !!(dialectOptionsOrDatabaseOptions as Exclude< 118 | DialectDatabaseOptions, 119 | BuiltInDatabaseDialect 120 | >)?.dialect 121 | ) || 122 | // Only dialect 123 | typeof dialectOptionsOrDatabaseOptions === "string"; 124 | } 125 | 126 | private static _convertDialectFormToConnectorForm( 127 | dialectOptionsOrDatabaseOptions: DialectDatabaseOptions, 128 | connectionOptions: ConnectorOptions, 129 | fromConstructor = true, 130 | ): ConnectorDatabaseOptions { 131 | if (typeof dialectOptionsOrDatabaseOptions === "string") { 132 | dialectOptionsOrDatabaseOptions = { 133 | dialect: dialectOptionsOrDatabaseOptions, 134 | }; 135 | } 136 | if ( 137 | fromConstructor && 138 | !dialectOptionsOrDatabaseOptions.disableDialectUsageDeprecationWarning 139 | ) { 140 | warning( 141 | "[denodb]: DEPRECATION warning, the usage with dialect instead of connector is deprecated and will be removed in future versions.\n" + 142 | "[denodb]: If you want to disable this warning pass `disableDialectUsageDeprecationWarning: true` with the dialect in the Database constructor.\n" + 143 | "[denodb]: If you want to migrate to the current behavior, visit https://github.com/eveningkid/denodb/blob/master/docs/v1.0.21-migrations/connectors.md for help", 144 | ); 145 | } 146 | 147 | return { 148 | connector: connectorFactory( 149 | dialectOptionsOrDatabaseOptions.dialect, 150 | connectionOptions, 151 | ), 152 | debug: dialectOptionsOrDatabaseOptions.debug, 153 | }; 154 | } 155 | 156 | static forDialect( 157 | dialectOptionsOrDatabaseOptions: Omit< 158 | DialectDatabaseOptions, 159 | "disableDialectUsageDeprecationWarning" 160 | >, 161 | connectionOptions: ConnectorOptions, 162 | ): Database { 163 | return new Database( 164 | Database._convertDialectFormToConnectorForm( 165 | dialectOptionsOrDatabaseOptions as DialectDatabaseOptions, 166 | connectionOptions, 167 | false, 168 | ), 169 | ); 170 | } 171 | 172 | /** Test database connection. */ 173 | ping() { 174 | return this.getConnector().ping(); 175 | } 176 | 177 | /** Get the database dialect. */ 178 | getDialect() { 179 | return this.getConnector()._dialect; 180 | } 181 | 182 | /* Get the database connector. */ 183 | getConnector() { 184 | return this._connector; 185 | } 186 | 187 | /* Get the database client. */ 188 | getClient() { 189 | return this.getConnector()._client; 190 | } 191 | 192 | /** Create the given models in the current database. 193 | * 194 | * await db.sync({ drop: true }); 195 | */ 196 | async sync(options: SyncOptions = {}) { 197 | if (options.drop) { 198 | for (let i = this._models.length - 1; i >= 0; i--) { 199 | await this._models[i].drop(); 200 | } 201 | } 202 | 203 | if (options.truncate) { 204 | for (let i = this._models.length - 1; i >= 0; i--) { 205 | await this._models[i].truncate(); 206 | } 207 | } 208 | 209 | for (const model of this._models) { 210 | await model.createTable(); 211 | } 212 | } 213 | 214 | /** Associate all the required information for a model to connect to a database. 215 | * 216 | * await db.link([Flight, Airport]); 217 | */ 218 | link(models: ModelSchema[]) { 219 | this._models = models; 220 | 221 | this._models.forEach((model) => 222 | model._link({ 223 | queryBuilder: this._queryBuilder, 224 | database: this, 225 | }) 226 | ); 227 | 228 | return this; 229 | } 230 | 231 | /** Pass on any query to the database. 232 | * 233 | * await db.query("SELECT * FROM `flights`"); 234 | */ 235 | async query(query: QueryDescription): Promise { 236 | if (this._debug) { 237 | console.log(query); 238 | } 239 | 240 | const results = await this.getConnector().query(query); 241 | 242 | return Array.isArray(results) 243 | ? results.map((result) => 244 | formatResultToModelInstance(query.schema, result) 245 | ) 246 | : formatResultToModelInstance(query.schema, results); 247 | } 248 | 249 | /** Execute queries within a transaction. */ 250 | transaction(queries: () => Promise) { 251 | if (!this.getConnector().transaction) { 252 | warning( 253 | "Transactions are not supported by this connector at the moment.", 254 | ); 255 | 256 | return Promise.resolve(); 257 | } 258 | 259 | return this.getConnector().transaction?.(queries); 260 | } 261 | 262 | /** Compute field matchings tables for model usage. */ 263 | _computeModelFieldMatchings( 264 | table: string, 265 | fields: ModelFields, 266 | withTimestamps: boolean, 267 | ): { 268 | toClient: FieldMatchingTable; 269 | toDatabase: FieldMatchingTable; 270 | } { 271 | const modelFields = { ...fields }; 272 | if (withTimestamps) { 273 | modelFields.updatedAt = ""; 274 | modelFields.createdAt = ""; 275 | } 276 | 277 | const toDatabase: FieldMatchingTable = Object.entries(modelFields).reduce( 278 | (prev: any, [clientFieldName, fieldType]) => { 279 | const databaseFieldName = typeof fieldType !== "string" && fieldType.as 280 | ? fieldType.as 281 | : (this._translator.formatFieldNameToDatabase( 282 | clientFieldName, 283 | ) as string); 284 | 285 | prev[clientFieldName] = databaseFieldName; 286 | prev[`${table}.${clientFieldName}`] = `${table}.${databaseFieldName}`; 287 | return prev; 288 | }, 289 | {}, 290 | ); 291 | 292 | const toClient: FieldMatchingTable = Object.entries(toDatabase).reduce( 293 | (prev, [clientFieldName, databaseFieldName]) => ({ 294 | ...prev, 295 | [databaseFieldName]: clientFieldName, 296 | }), 297 | {}, 298 | ); 299 | 300 | return { toDatabase, toClient }; 301 | } 302 | 303 | /** Close the current database connection. */ 304 | close() { 305 | return this.getConnector().close(); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /lib/helpers/fields.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DataTypes, 3 | FieldOptions, 4 | FieldProps, 5 | FieldType, 6 | FieldTypeString, 7 | } from "../data-types.ts"; 8 | 9 | /** Add a model field to a table schema. */ 10 | export function addFieldToSchema( 11 | table: any, 12 | fieldOptions: FieldOptions, 13 | ) { 14 | const type = typeof fieldOptions.type === "string" 15 | ? fieldOptions.type 16 | : fieldOptions.type.type!; 17 | 18 | let instruction; 19 | 20 | if (typeof fieldOptions.type === "object") { 21 | if (fieldOptions.type.relationship) { 22 | const relationshipPKName = fieldOptions.type.relationship.model 23 | .getComputedPrimaryKey(); 24 | 25 | const relationshipPKProps: FieldProps = fieldOptions.type.relationship 26 | .model 27 | .getComputedPrimaryProps(); 28 | 29 | const relationshipPKType: FieldTypeString = fieldOptions.type.relationship 30 | .model 31 | .getComputedPrimaryType(); 32 | 33 | if (relationshipPKType === "integer" || relationshipPKType === "bigInteger") { 34 | const foreignField = table[relationshipPKType](fieldOptions.name); 35 | 36 | if (!relationshipPKProps.allowNull) { 37 | foreignField.notNullable(); 38 | } 39 | 40 | if (relationshipPKProps.autoIncrement) { 41 | foreignField.unsigned(); 42 | } 43 | } else { 44 | table[relationshipPKType](fieldOptions.name); 45 | } 46 | 47 | table 48 | .foreign(fieldOptions.name) 49 | .references( 50 | fieldOptions.type.relationship.model 51 | .field(relationshipPKName), 52 | ) 53 | .onDelete("CASCADE"); 54 | 55 | return; 56 | } 57 | 58 | const fieldNameArgs: [string | number | (string | number)[]] = [ 59 | fieldOptions.name, 60 | ]; 61 | 62 | if (fieldOptions.type.length) { 63 | fieldNameArgs.push(fieldOptions.type.length); 64 | } 65 | 66 | if (fieldOptions.type.precision) { 67 | fieldNameArgs.push(fieldOptions.type.precision); 68 | } 69 | 70 | if (fieldOptions.type.scale) { 71 | fieldNameArgs.push(fieldOptions.type.scale); 72 | } 73 | 74 | if (fieldOptions.type.values) { 75 | fieldNameArgs.push(fieldOptions.type.values); 76 | } 77 | 78 | if (fieldOptions.type.autoIncrement) { 79 | instruction = fieldOptions.type.type === "bigInteger" ? table.bigincrements(fieldOptions.name) : table.increments(fieldOptions.name); 80 | } else { 81 | instruction = table[type](...fieldNameArgs); 82 | } 83 | 84 | if (fieldOptions.type.primaryKey) { 85 | instruction = instruction.primary(fieldOptions.name); 86 | } 87 | 88 | if (fieldOptions.type.unique) { 89 | instruction = instruction.unique(fieldOptions.name); 90 | } 91 | 92 | if (!fieldOptions.type.allowNull) { 93 | instruction = instruction.notNullable(); 94 | } 95 | } else { 96 | instruction = table[type](fieldOptions.name); 97 | } 98 | 99 | if (typeof fieldOptions.type === "object" && fieldOptions.type.comment) { 100 | instruction.comment(fieldOptions.type.comment); 101 | } 102 | 103 | if (typeof fieldOptions.defaultValue !== "undefined") { 104 | instruction.defaultTo(fieldOptions.defaultValue); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lib/helpers/log.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleColor } from "../../deps.ts"; 2 | 3 | export function success(message: string) { 4 | console.log(ConsoleColor.success(message)); 5 | } 6 | 7 | export function error(message: string) { 8 | console.log(ConsoleColor.error(message)); 9 | } 10 | 11 | export function warning(message: string) { 12 | console.log(ConsoleColor.warning(message)); 13 | } 14 | -------------------------------------------------------------------------------- /lib/helpers/results.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSchema } from "../model.ts"; 2 | import type { Values } from "../data-types.ts"; 3 | 4 | /** Transform a plain record object to a given model schema. */ 5 | export function formatResultToModelInstance( 6 | Schema: ModelSchema, 7 | fields: Values, 8 | ) { 9 | const instance = new Schema(); 10 | 11 | for (const field in fields) { 12 | (instance as any)[Schema.formatFieldToClient(field) as string] = 13 | fields[field]; 14 | } 15 | 16 | return instance; 17 | } 18 | -------------------------------------------------------------------------------- /lib/model-pivot.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelSchema } from "./model.ts"; 2 | 3 | export type PivotModelSchema = typeof PivotModel; 4 | 5 | export class PivotModel extends Model { 6 | static _pivotsModels: { [modelName: string]: ModelSchema } = {}; 7 | static _pivotsFields: { [modelName: string]: string } = {}; 8 | } 9 | -------------------------------------------------------------------------------- /lib/model.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Operator, 3 | OrderByClauses, 4 | OrderDirection, 5 | QueryBuilder, 6 | QueryDescription, 7 | QueryType, 8 | } from "./query-builder.ts"; 9 | import type { Database } from "./database.ts"; 10 | import type { PivotModelSchema } from "./model-pivot.ts"; 11 | import { camelCase } from "../deps.ts"; 12 | import { 13 | DataTypes, 14 | FieldAlias, 15 | FieldOptions, 16 | FieldProps, 17 | FieldType, 18 | FieldTypeString, 19 | FieldValue, 20 | Values, 21 | } from "./data-types.ts"; 22 | 23 | /** Represents a Model class, not an instance. */ 24 | export type ModelSchema = typeof Model; 25 | 26 | export type ModelFields = { [key: string]: FieldType }; 27 | export type ModelDefaults = { 28 | [field: string]: FieldValue | (() => FieldValue); 29 | }; 30 | export type ModelPivotModels = { [modelName: string]: PivotModelSchema }; 31 | export type FieldMatchingTable = { [clientField: string]: string }; 32 | 33 | export type ModelOptions = { 34 | queryBuilder: QueryBuilder; 35 | database: Database; 36 | }; 37 | 38 | export type AggregationResult = Model & { 39 | avg?: number; 40 | count?: number; 41 | max?: number; 42 | min?: number; 43 | sum?: number; 44 | }; 45 | 46 | export type ModelEventType = 47 | | "creating" 48 | | "created" 49 | | "updating" 50 | | "updated" 51 | | "deleting" 52 | | "deleted"; 53 | 54 | export type ModelEventListenerWithModel = (model: Model) => void; 55 | export type ModelEventListenerWithoutModel = (model?: Model) => void; 56 | export type ModelEventListener = 57 | | ModelEventListenerWithoutModel 58 | | ModelEventListenerWithModel; 59 | 60 | export type ModelEventListeners = { 61 | [eventType in ModelEventType]?: ModelEventListener[]; 62 | }; 63 | 64 | /** Model that can be used with a `Database`. */ 65 | export class Model { 66 | [attribute: string]: FieldValue | Function 67 | 68 | /** Table name as it should be saved in the database. */ 69 | static table = ""; 70 | 71 | /** Should this model have `created_at` and `updated_at` fields by default. */ 72 | static timestamps = false; 73 | 74 | /** Model fields. */ 75 | static fields: ModelFields = {}; 76 | 77 | /** Default values for the model fields. */ 78 | static defaults: ModelDefaults = {}; 79 | 80 | /** Pivot table to use for a given model. */ 81 | static pivot: ModelPivotModels = {}; 82 | 83 | /** If the model has been created in the database. */ 84 | private static _isCreatedInDatabase = false; 85 | 86 | /** Query builder instance. */ 87 | private static _queryBuilder: QueryBuilder; 88 | 89 | /** Database which this model will be attached to. */ 90 | private static _database: Database; 91 | 92 | /** Model primary key. Manually found through `_findPrimaryKey()`. */ 93 | private static _primaryKey: string; 94 | 95 | /** Model field matching, from database to client and vice versa. */ 96 | private static _fieldMatching: { 97 | toDatabase: FieldMatchingTable; 98 | toClient: FieldMatchingTable; 99 | } = { 100 | toDatabase: {}, 101 | toClient: {}, 102 | }; 103 | 104 | /** Model current query being built. */ 105 | private static _currentQuery: QueryBuilder; 106 | 107 | /** Options this model was initialized with. */ 108 | private static _options: ModelOptions; 109 | 110 | /** Attached event listeners. */ 111 | private static _listeners: ModelEventListeners = {}; 112 | 113 | /** Link a model to a database. Should not be called from a child model. */ 114 | static _link(options: ModelOptions) { 115 | this._options = options; 116 | this._database = options.database; 117 | this._queryBuilder = options.queryBuilder; 118 | 119 | this._fieldMatching = this._database._computeModelFieldMatchings( 120 | this.name, 121 | this.fields, 122 | this.timestamps, 123 | ); 124 | 125 | this._currentQuery = this._queryBuilder.queryForSchema(this); 126 | this._primaryKey = this._findPrimaryKey(); 127 | } 128 | 129 | /** Drop a model in the database. */ 130 | static async drop() { 131 | const dropQuery = this._options.queryBuilder 132 | .queryForSchema(this) 133 | .table(this.table) 134 | .dropIfExists() 135 | .toDescription(); 136 | 137 | await this._options.database.query(dropQuery); 138 | 139 | this._isCreatedInDatabase = false; 140 | } 141 | 142 | /** Truncate a model in the database. */ 143 | static async truncate() { 144 | const truncateQuery = this._options.queryBuilder 145 | .queryForSchema(this) 146 | .table(this.table) 147 | .truncate() 148 | .toDescription(); 149 | 150 | await this._options.database.query(truncateQuery); 151 | } 152 | 153 | /** Create a model in the database. Should not be called from a child model. */ 154 | static async createTable() { 155 | if (this._isCreatedInDatabase) { 156 | throw new Error("This model has already been initialized."); 157 | } 158 | 159 | const createQuery = this._options.queryBuilder 160 | .queryForSchema(this) 161 | .table(this.table) 162 | .createTable( 163 | this.formatFieldToDatabase(this.fields) as ModelFields, 164 | this.formatFieldToDatabase(this.defaults) as ModelDefaults, 165 | { 166 | withTimestamps: this.timestamps, 167 | ifNotExists: true, 168 | }, 169 | ) 170 | .toDescription(); 171 | 172 | await this._options.database.query(createQuery); 173 | 174 | this._isCreatedInDatabase = true; 175 | } 176 | 177 | /** Manually find the primary field by going through the schema fields. */ 178 | private static _findPrimaryField(): FieldOptions { 179 | const field = Object.entries(this.fields).find( 180 | ([_, fieldType]) => typeof fieldType === "object" && fieldType.primaryKey, 181 | ); 182 | 183 | return { 184 | name: field ? (this.formatFieldToDatabase(field[0]) as string) : "", 185 | type: field ? field[1] : DataTypes.INTEGER, 186 | defaultValue: 0, 187 | }; 188 | } 189 | 190 | /** Manually find the primary key by going through the schema fields. */ 191 | private static _findPrimaryKey(): string { 192 | return this._findPrimaryField().name; 193 | } 194 | 195 | /** Return the model computed primary key. */ 196 | static getComputedPrimaryKey(): string { 197 | if (!this._primaryKey) { 198 | this._primaryKey = this._findPrimaryKey(); 199 | } 200 | 201 | return this._primaryKey; 202 | } 203 | 204 | /** Return the field type of the primary key. */ 205 | static getComputedPrimaryType(): FieldTypeString { 206 | const field = this._findPrimaryField(); 207 | 208 | return typeof field.type === "object" 209 | ? (field.type as any).type 210 | : field.type; 211 | } 212 | 213 | /** Return the field properties of the primary key */ 214 | static getComputedPrimaryProps(): FieldProps { 215 | const field = this._findPrimaryField(); 216 | 217 | return typeof field === "object" ? field.type : {}; 218 | } 219 | 220 | /** Build the current query and run it on the associated database. */ 221 | private static async _runQuery(query: QueryDescription) { 222 | this._currentQuery = this._queryBuilder.queryForSchema(this); 223 | 224 | if (query.type) { 225 | this._runEventListeners(query.type); 226 | } 227 | 228 | const results = await this._database.query(query); 229 | 230 | if (query.type) { 231 | this._runEventListeners(query.type, results); 232 | } 233 | 234 | return results; 235 | } 236 | 237 | /** Format a field or an object of fields, following a field matching table. 238 | * Defaulting to `defaultCase` or `field` otherwise. */ 239 | private static _formatField( 240 | fieldMatching: FieldMatchingTable, 241 | field: string | { [fieldName: string]: any }, 242 | defaultCase?: (field: string) => string, 243 | ): string | { [fieldName: string]: any } { 244 | if (typeof field !== "string") { 245 | return Object.entries(field).reduce((prev: any, [fieldName, value]) => { 246 | prev[this._formatField(fieldMatching, fieldName) as string] = value; 247 | return prev; 248 | }, {}) as { [fieldName: string]: any }; 249 | } 250 | 251 | if (field in fieldMatching) { 252 | return fieldMatching[field]; 253 | } 254 | 255 | return defaultCase ? defaultCase(field) : field; 256 | } 257 | 258 | /** Format field or an object of fields from client to database. */ 259 | static formatFieldToDatabase(field: string | Object) { 260 | return this._formatField(this._fieldMatching.toDatabase, field); 261 | } 262 | 263 | /** Format field or an object of fields from database to client. */ 264 | static formatFieldToClient(field: string | Object) { 265 | return this._formatField(this._fieldMatching.toClient, field, camelCase); 266 | } 267 | 268 | /* Wraps values with defaults. */ 269 | private static _wrapValuesWithDefaults(values: Values): Values { 270 | for (const field of Object.keys(this.fields)) { 271 | if (values.hasOwnProperty(field)) { 272 | continue; 273 | } 274 | 275 | if (this.defaults.hasOwnProperty(field)) { 276 | const defaultValue = this.defaults[field]; 277 | 278 | if (typeof defaultValue === "function") { 279 | values[field] = defaultValue(); 280 | } else { 281 | values[field] = defaultValue; 282 | } 283 | } 284 | } 285 | 286 | return values; 287 | } 288 | 289 | /** Add an event listener for a specific operation/hook. 290 | * 291 | * Flight.on('created', (model) => console.log('New model:', model)); 292 | */ 293 | static on( 294 | this: T, 295 | eventType: ModelEventType, 296 | callback: ModelEventListener, 297 | ) { 298 | if (!(eventType in this._listeners)) { 299 | this._listeners[eventType] = []; 300 | } 301 | 302 | this._listeners[eventType]!.push(callback); 303 | 304 | return this; 305 | } 306 | 307 | /** Alias for `Model.on`, add an event listener for a specific operation/hook. 308 | * 309 | * Flight.addEventListener('created', (model) => console.log('New model:', model)); 310 | */ 311 | static addEventListener( 312 | this: T, 313 | eventType: ModelEventType, 314 | callback: ModelEventListener, 315 | ) { 316 | return this.on(eventType, callback); 317 | } 318 | 319 | static removeEventListener( 320 | eventType: ModelEventType, 321 | callback: ModelEventListener, 322 | ) { 323 | if (!(eventType in this._listeners)) { 324 | throw new Error( 325 | `There is no event listener for ${eventType}. You might be trying to remove a listener that you haven't added with Model.on('${eventType}', ...).`, 326 | ); 327 | } 328 | 329 | this._listeners[eventType] = this._listeners[eventType]!.filter(( 330 | listener, 331 | ) => listener !== callback); 332 | 333 | return this; 334 | } 335 | 336 | /** Run event listeners given a query type and results. */ 337 | private static _runEventListeners( 338 | queryType: QueryType, 339 | instances?: Model | Model[], 340 | ) { 341 | // -ing => present, -ed => past 342 | const isPastEvent = !!instances; 343 | 344 | let eventType: ModelEventType; 345 | switch (queryType) { 346 | case "insert": 347 | eventType = isPastEvent ? "created" : "creating"; 348 | break; 349 | 350 | case "update": 351 | eventType = isPastEvent ? "updated" : "updating"; 352 | break; 353 | 354 | case "delete": 355 | eventType = isPastEvent ? "deleted" : "deleting"; 356 | break; 357 | 358 | default: 359 | return; 360 | } 361 | 362 | const listeners = this._listeners[eventType]; 363 | 364 | if (!listeners) { 365 | return; 366 | } 367 | 368 | for (const listener of listeners) { 369 | if (instances) { 370 | if (Array.isArray(instances)) { 371 | if (instances.length > 0) { 372 | instances.forEach(listener); 373 | } else { 374 | (listener as ModelEventListenerWithoutModel)(); 375 | } 376 | } else { 377 | listener(instances); 378 | } 379 | } else { 380 | (listener as ModelEventListenerWithoutModel)(); 381 | } 382 | } 383 | } 384 | 385 | /** Return the table name followed by a field name. Can also rename a field using `nameAs`. 386 | * 387 | * Flight.field("departure") => "flights.departure" 388 | * 389 | * Flight.field("id", "flight_id") => { flight_id: "flights.id" } 390 | */ 391 | static field(field: string): string; 392 | static field(field: string, nameAs: string): FieldAlias; 393 | static field(field: string, nameAs?: string): string | FieldAlias { 394 | const fullField = this.formatFieldToDatabase( 395 | `${this.table}.${field}`, 396 | ) as string; 397 | 398 | if (nameAs) { 399 | return { [nameAs]: fullField }; 400 | } 401 | 402 | return fullField; 403 | } 404 | 405 | /** Run the current query. */ 406 | static get() { 407 | return this._runQuery( 408 | this._currentQuery.table(this.table).get().toDescription(), 409 | ); 410 | } 411 | 412 | /** Fetch all the model records. 413 | * 414 | * await Flight.all(); 415 | * 416 | * await Flight.select("id").all(); 417 | */ 418 | static all() { 419 | return this.get() as Promise; 420 | } 421 | 422 | /** Indicate which fields should be returned/selected from the query. 423 | * 424 | * await Flight.select("id").get(); 425 | * 426 | * await Flight.select("id", "destination").get(); 427 | */ 428 | static select( 429 | this: T, 430 | ...fields: (string | FieldAlias)[] 431 | ) { 432 | this._currentQuery.select( 433 | ...fields.map((field) => this.formatFieldToDatabase(field)), 434 | ); 435 | return this; 436 | } 437 | 438 | /** Create one or multiple records in the current model. 439 | * 440 | * await Flight.create({ departure: "Paris", destination: "Tokyo" }); 441 | * 442 | * await Flight.create([{ ... }, { ... }]); 443 | */ 444 | static async create(values: Values): Promise; 445 | static async create(values: Values[]): Promise; 446 | static async create(values: Values | Values[]) { 447 | const insertions = Array.isArray(values) ? values : [values]; 448 | 449 | const results = await this._runQuery( 450 | this._currentQuery.table(this.table).create( 451 | insertions.map((field) => 452 | this.formatFieldToDatabase(this._wrapValuesWithDefaults(field)) 453 | ) as Values[], 454 | ).toDescription(), 455 | ); 456 | 457 | if (!Array.isArray(values) && Array.isArray(results)) { 458 | return results[0]; 459 | } 460 | 461 | return results; 462 | } 463 | 464 | /** Find one or multiple records based on the model primary key. 465 | * 466 | * await Flight.find("1"); 467 | */ 468 | static async find(idOrIds: FieldValue): Promise; 469 | static async find(idOrIds: FieldValue[]): Promise; 470 | static async find(idOrIds: FieldValue | FieldValue[]) { 471 | const results = await this._runQuery( 472 | this._currentQuery 473 | .table(this.table) 474 | .find( 475 | this.getComputedPrimaryKey(), 476 | Array.isArray(idOrIds) ? idOrIds : [idOrIds], 477 | ) 478 | .toDescription(), 479 | ); 480 | 481 | return Array.isArray(idOrIds) ? results : (results as Model[])[0]; 482 | } 483 | 484 | /** Order query results based on a field name and an optional direction. 485 | * 486 | * await Flight.orderBy("departure").all(); 487 | * 488 | * await Flight.orderBy("departure", "desc").all(); 489 | * 490 | * await Flight.orderBy({ departure: "desc", destination: "asc" }).all(); 491 | */ 492 | static orderBy( 493 | this: T, 494 | fieldOrFields: string | OrderByClauses, 495 | orderDirection: OrderDirection = "asc", 496 | ) { 497 | if (typeof fieldOrFields === "string") { 498 | this._currentQuery.orderBy( 499 | this.formatFieldToDatabase(fieldOrFields) as string, 500 | orderDirection, 501 | ); 502 | } else { 503 | for ( 504 | const [field, orderDirectionField] of Object.entries( 505 | fieldOrFields, 506 | ) 507 | ) { 508 | this._currentQuery.orderBy( 509 | this.formatFieldToDatabase(field) as string, 510 | orderDirectionField, 511 | ); 512 | } 513 | } 514 | 515 | return this; 516 | } 517 | 518 | /** Group rows by a given field. 519 | * 520 | * await Flight.groupBy('departure').all(); 521 | */ 522 | static groupBy(this: T, field: string) { 523 | this._currentQuery.groupBy(this.formatFieldToDatabase(field) as string); 524 | return this; 525 | } 526 | 527 | /** Similar to `limit`, limit the number of results returned from the query. 528 | * 529 | * await Flight.take(10).get(); 530 | */ 531 | static take(this: T, limit: number) { 532 | return this.limit(limit); 533 | } 534 | 535 | /** Limit the number of results returned from the query. 536 | * 537 | * await Flight.limit(10).get(); 538 | */ 539 | static limit(this: T, limit: number) { 540 | this._currentQuery.limit(limit); 541 | return this; 542 | } 543 | 544 | /** Return the first record that matches the current query. 545 | * 546 | * await Flight.where("id", ">", "1").first(); 547 | */ 548 | static async first() { 549 | this.take(1); 550 | const results = await this.get(); 551 | return (results as Model[])[0]; 552 | } 553 | 554 | /** Skip n values in the results. 555 | * 556 | * await Flight.offset(10).get(); 557 | * 558 | * await Flight.offset(10).limit(2).get(); 559 | */ 560 | static offset(this: T, offset: number) { 561 | this._currentQuery.offset(offset); 562 | return this; 563 | } 564 | 565 | /** Similar to `offset`, skip n values in the results. 566 | * 567 | * await Flight.skip(10).get(); 568 | * 569 | * await Flight.skip(10).take(2).get(); 570 | */ 571 | static skip(this: T, offset: number) { 572 | return this.offset(offset); 573 | } 574 | 575 | /** Add a `where` clause to your query. 576 | * 577 | * await Flight.where("id", "1").get(); 578 | * 579 | * await Flight.where("id", ">", "1").get(); 580 | * 581 | * await Flight.where({ id: "1", departure: "Paris" }).get(); 582 | */ 583 | static where( 584 | this: T, 585 | field: string, 586 | fieldValue: FieldValue, 587 | ): T; 588 | static where( 589 | this: T, 590 | field: string, 591 | operator: Operator, 592 | fieldValue: FieldValue, 593 | ): T; 594 | static where(this: T, fields: Values): T; 595 | static where( 596 | this: T, 597 | fieldOrFields: string | Values, 598 | operatorOrFieldValue?: Operator | FieldValue, 599 | fieldValue?: FieldValue, 600 | ) { 601 | if (typeof fieldOrFields === "string") { 602 | const whereOperator: Operator = typeof fieldValue !== "undefined" 603 | ? (operatorOrFieldValue as Operator) 604 | : "="; 605 | 606 | const whereValue: FieldValue = typeof fieldValue !== "undefined" 607 | ? fieldValue 608 | : (operatorOrFieldValue as FieldValue); 609 | 610 | if (whereValue !== undefined) { 611 | this._currentQuery.where( 612 | this.formatFieldToDatabase(fieldOrFields) as string, 613 | whereOperator, 614 | whereValue, 615 | ); 616 | } 617 | } else { 618 | // TODO(eveningkid): cannot do multiple where with different operators 619 | // Need to find a great API for multiple where potentially with operators 620 | // .where({ name: 'John', age: { moreThan: 19 } }) 621 | // and then format it using Knex .andWhere(...) 622 | 623 | for (const [field, value] of Object.entries(fieldOrFields)) { 624 | if (value === undefined) { 625 | continue; 626 | } 627 | 628 | this._currentQuery.where( 629 | this.formatFieldToDatabase(field) as string, 630 | "=", 631 | value, 632 | ); 633 | } 634 | } 635 | 636 | return this; 637 | } 638 | 639 | /** Update one or multiple records. Also update `updated_at` if `timestamps` is `true`. 640 | * 641 | * await Flight.where("departure", "Dublin").update("departure", "Tokyo"); 642 | * 643 | * await Flight.where("departure", "Dublin").update({ destination: "Tokyo" }); 644 | */ 645 | static update(fieldOrFields: string | Values, fieldValue?: FieldValue) { 646 | let fieldsToUpdate: Values = {}; 647 | 648 | if (this.timestamps) { 649 | fieldsToUpdate[ 650 | this.formatFieldToDatabase("updated_at") as string 651 | ] = new Date(); 652 | } 653 | 654 | if (typeof fieldOrFields === "string") { 655 | fieldsToUpdate[ 656 | this.formatFieldToDatabase(fieldOrFields) as string 657 | ] = fieldValue!; 658 | } else { 659 | fieldsToUpdate = { 660 | ...fieldsToUpdate, 661 | ...(this.formatFieldToDatabase(fieldOrFields) as { 662 | [fieldName: string]: any; 663 | }), 664 | }; 665 | } 666 | 667 | return this._runQuery( 668 | this._currentQuery 669 | .table(this.table) 670 | .update(fieldsToUpdate) 671 | .toDescription(), 672 | ) as Promise; 673 | } 674 | 675 | /** Delete a record by a primary key value. 676 | * 677 | * await Flight.deleteById("1"); 678 | */ 679 | static deleteById(id: FieldValue) { 680 | return this._runQuery( 681 | this._currentQuery 682 | .table(this.table) 683 | .where(this.getComputedPrimaryKey(), "=", id) 684 | .delete() 685 | .toDescription(), 686 | ); 687 | } 688 | 689 | /** Delete selected records. 690 | * 691 | * await Flight.where("destination", "Paris").delete(); 692 | */ 693 | static delete() { 694 | return this._runQuery( 695 | this._currentQuery.table(this.table).delete().toDescription(), 696 | ); 697 | } 698 | 699 | /** Join a table to the current query. 700 | * 701 | * await Flight.where( 702 | * Flight.field("departure"), 703 | * "Paris", 704 | * ).join( 705 | * Airport, 706 | * Airport.field("id"), 707 | * Flight.field("airportId"), 708 | * ).get() 709 | */ 710 | static join( 711 | this: T, 712 | joinTable: ModelSchema, 713 | originField: string, 714 | targetField: string, 715 | ) { 716 | this._currentQuery.join( 717 | joinTable.table, 718 | joinTable.formatFieldToDatabase(originField) as string, 719 | this.formatFieldToDatabase(targetField) as string, 720 | ); 721 | return this; 722 | } 723 | 724 | /** Join a table with left outer statement to the current query. 725 | * 726 | * await Flight.where( 727 | * Flight.field("departure"), 728 | * "Paris", 729 | * ).leftOuterJoin( 730 | * Airport, 731 | * Airport.field("id"), 732 | * Flight.field("airportId"), 733 | * ).get() 734 | */ 735 | static leftOuterJoin( 736 | this: T, 737 | joinTable: ModelSchema, 738 | originField: string, 739 | targetField: string, 740 | ) { 741 | this._currentQuery.leftOuterJoin( 742 | joinTable.table, 743 | joinTable.formatFieldToDatabase(originField) as string, 744 | this.formatFieldToDatabase(targetField) as string, 745 | ); 746 | return this; 747 | } 748 | 749 | /** Join a table with left statement to the current query. 750 | * 751 | * await Flight.where( 752 | * Flight.field("departure"), 753 | * "Paris", 754 | * ).leftJoin( 755 | * Airport, 756 | * Airport.field("id"), 757 | * Flight.field("airportId"), 758 | * ).get() 759 | */ 760 | static leftJoin( 761 | this: T, 762 | joinTable: ModelSchema, 763 | originField: string, 764 | targetField: string, 765 | ) { 766 | this._currentQuery.leftJoin( 767 | joinTable.table, 768 | joinTable.formatFieldToDatabase(originField) as string, 769 | this.formatFieldToDatabase(targetField) as string, 770 | ); 771 | return this; 772 | } 773 | 774 | /** Count the number of records of a model or filtered by a field name. 775 | * 776 | * await Flight.count(); 777 | * 778 | * await Flight.where("destination", "Dublin").count(); 779 | */ 780 | static async count(field = "*") { 781 | const value = await this._runQuery( 782 | this._currentQuery 783 | .table(this.table) 784 | .count(this.formatFieldToDatabase(field) as string) 785 | .toDescription(), 786 | ); 787 | 788 | return Number((value as AggregationResult[])[0].count); 789 | } 790 | 791 | /** Find the minimum value of a field from all the selected records. 792 | * 793 | * await Flight.min("flightDuration"); 794 | */ 795 | static async min(field: string) { 796 | const value = await this._runQuery( 797 | this._currentQuery 798 | .table(this.table) 799 | .min(this.formatFieldToDatabase(field) as string) 800 | .toDescription(), 801 | ); 802 | 803 | return Number((value as AggregationResult[])[0].min); 804 | } 805 | 806 | /** Find the maximum value of a field from all the selected records. 807 | * 808 | * await Flight.max("flightDuration"); 809 | */ 810 | static async max(field: string) { 811 | const value = await this._runQuery( 812 | this._currentQuery 813 | .table(this.table) 814 | .max(this.formatFieldToDatabase(field) as string) 815 | .toDescription(), 816 | ); 817 | 818 | return Number((value as AggregationResult[])[0].max); 819 | } 820 | 821 | /** Compute the sum of a field's values from all the selected records. 822 | * 823 | * await Flight.sum("flightDuration"); 824 | */ 825 | static async sum(field: string) { 826 | const value = await this._runQuery( 827 | this._currentQuery 828 | .table(this.table) 829 | .sum(this.formatFieldToDatabase(field) as string) 830 | .toDescription(), 831 | ); 832 | 833 | return Number((value as AggregationResult[])[0].sum); 834 | } 835 | 836 | /** Compute the average value of a field's values from all the selected records. 837 | * 838 | * await Flight.avg("flightDuration"); 839 | * 840 | * await Flight.where("destination", "San Francisco").avg("flightDuration"); 841 | */ 842 | static async avg(field: string) { 843 | const value = await this._runQuery( 844 | this._currentQuery 845 | .table(this.table) 846 | .avg(this.formatFieldToDatabase(field) as string) 847 | .toDescription(), 848 | ); 849 | 850 | return Number((value as AggregationResult[])[0].avg); 851 | } 852 | 853 | /** Find associated values for the given model for one-to-many and many-to-many relationships. 854 | * 855 | * class Airport { 856 | * static flights() { 857 | * return this.hasMany(Flight); 858 | * } 859 | * } 860 | * 861 | * Airport.where("id", "1").flights(); 862 | */ 863 | static hasMany( 864 | this: T, 865 | model: ModelSchema, 866 | ): Promise { 867 | const currentWhereValue = this._findCurrentQueryWhereClause(); 868 | 869 | if (model.name in this.pivot) { 870 | const pivot = this.pivot[model.name]; 871 | const pivotField = this.formatFieldToDatabase( 872 | pivot._pivotsFields[this.name], 873 | ) as string; 874 | const pivotOtherModel = pivot._pivotsModels[model.name]; 875 | const pivotOtherModelField = pivotOtherModel.formatFieldToDatabase( 876 | pivot._pivotsFields[model.name], 877 | ) as string; 878 | 879 | return pivot 880 | .where(pivot.field(pivotField), currentWhereValue) 881 | .join( 882 | pivotOtherModel, 883 | pivotOtherModel.field(pivotOtherModel.getComputedPrimaryKey()), 884 | pivot.field(pivotOtherModelField), 885 | ) 886 | .get(); 887 | } 888 | 889 | const foreignKeyName = this._findModelForeignKeyField(model); 890 | this._currentQuery = this._queryBuilder.queryForSchema(this); 891 | return model.where(foreignKeyName, currentWhereValue).all(); 892 | } 893 | 894 | /** Find associated values for the given model for one-to-one and one-to-many relationships. */ 895 | static async hasOne(this: T, model: ModelSchema) { 896 | const currentWhereValue = this._findCurrentQueryWhereClause(); 897 | const FKName = this._findModelForeignKeyField(model); 898 | 899 | if (!FKName) { 900 | const currentModelFKName = this._findModelForeignKeyField(this, model); 901 | const currentModelValue = await this.where( 902 | this.getComputedPrimaryKey(), 903 | currentWhereValue, 904 | ).first(); 905 | const currentModelFKValue = 906 | currentModelValue[currentModelFKName] as FieldValue; 907 | return model.where(model.getComputedPrimaryKey(), currentModelFKValue) 908 | .first(); 909 | } 910 | 911 | return model.where(FKName, currentWhereValue).first(); 912 | } 913 | 914 | /** Look for the current query's where clause for this model's primary key. */ 915 | private static _findCurrentQueryWhereClause() { 916 | if (!this._currentQuery._query.wheres) { 917 | throw new Error("The current query does not have any where clause."); 918 | } 919 | 920 | const where = this._currentQuery._query.wheres.find((where) => { 921 | return where.field === this.getComputedPrimaryKey(); 922 | }); 923 | 924 | if (!where) { 925 | throw new Error( 926 | "The current query does not have any where clause for this model primary key.", 927 | ); 928 | } 929 | 930 | return where.value; 931 | } 932 | 933 | /** Look for a `fieldName: Relationships.belongsTo(forModel)` field for a given `model`. */ 934 | private static _findModelForeignKeyField( 935 | model: ModelSchema, 936 | forModel: ModelSchema = this, 937 | ): string { 938 | const modelFK: [string, FieldType] | undefined = Object.entries( 939 | model.fields, 940 | ).find(([, type]) => { 941 | return typeof type === "object" 942 | ? type.relationship?.model === forModel 943 | : false; 944 | }); 945 | 946 | if (!modelFK) { 947 | return ""; 948 | } 949 | 950 | return modelFK[0]; 951 | } 952 | 953 | /** Return the instance current value for its primary key. */ 954 | private _getCurrentPrimaryKey() { 955 | const model = this.constructor as ModelSchema; 956 | return (this as any)[model.getComputedPrimaryKey()] as string; 957 | } 958 | 959 | /** Create a new record for the model. 960 | * 961 | * const flight = new Flight(); 962 | * flight.departure = "Toronto"; 963 | * flight.destination = "Paris"; 964 | * await flight.save(); 965 | */ 966 | async save() { 967 | const model = this.constructor as ModelSchema; 968 | const values: Values = {}; 969 | 970 | for (const field of Object.keys(model.fields)) { 971 | if (this.hasOwnProperty(field)) { 972 | values[field] = (this as any)[field]; 973 | } 974 | } 975 | 976 | const createdInstance = await model.create(values); 977 | 978 | for (const field in createdInstance) { 979 | (this as any)[field] = (createdInstance as any)[field]; 980 | } 981 | 982 | return this; 983 | } 984 | 985 | /** Update this record using its current field values. 986 | * 987 | * flight.destination = "London"; 988 | * await flight.update(); 989 | */ 990 | async update() { 991 | const model = this.constructor as ModelSchema; 992 | const modelPK = model.getComputedPrimaryKey(); 993 | 994 | const values: Values = {}; 995 | for (const field of Object.keys(model.fields)) { 996 | if (this.hasOwnProperty(field) && field !== modelPK) { 997 | values[field] = (this as any)[field]; 998 | } 999 | } 1000 | 1001 | await model.where(modelPK, this._getCurrentPrimaryKey()).update( 1002 | values, 1003 | ); 1004 | 1005 | return this; 1006 | } 1007 | 1008 | /** Delete this record from the database. 1009 | * 1010 | * await flight.delete(); 1011 | */ 1012 | delete() { 1013 | const model = this.constructor as ModelSchema; 1014 | const PKCurrentValue = this._getCurrentPrimaryKey(); 1015 | 1016 | if (PKCurrentValue === undefined) { 1017 | throw new Error( 1018 | "This instance does not have a value for its primary key. It cannot be deleted.", 1019 | ); 1020 | } 1021 | 1022 | return model.deleteById(PKCurrentValue); 1023 | } 1024 | } 1025 | -------------------------------------------------------------------------------- /lib/query-builder.ts: -------------------------------------------------------------------------------- 1 | import type { SQLQueryBuilder } from "../deps.ts"; 2 | import type { FieldAlias, FieldValue, Values } from "./data-types.ts"; 3 | import { Model, ModelDefaults, ModelFields, ModelSchema } from "./model.ts"; 4 | 5 | export type Query = string; 6 | export type Operator = ">" | ">=" | "<" | "<=" | "=" | "like"; 7 | export type OrderDirection = "desc" | "asc"; 8 | export type QueryType = 9 | | "create" 10 | | "drop" 11 | | "truncate" 12 | | "select" 13 | | "insert" 14 | | "update" 15 | | "delete" 16 | | "count" 17 | | "min" 18 | | "max" 19 | | "avg" 20 | | "sum"; 21 | 22 | export type JoinClause = { 23 | joinTable: string; 24 | originField: string; 25 | targetField: string; 26 | }; 27 | 28 | export type WhereClause = { 29 | field: string; 30 | operator: Operator; 31 | value: FieldValue; 32 | }; 33 | 34 | export type WhereInClause = { 35 | field: string; 36 | possibleValues: FieldValue[]; 37 | }; 38 | 39 | export type OrderByClauses = { 40 | [field: string]: OrderDirection; 41 | }; 42 | 43 | export type QueryDescription = { 44 | schema: ModelSchema; 45 | type?: QueryType; 46 | table?: string; 47 | select?: (string | FieldAlias)[]; 48 | orderBy?: OrderByClauses; 49 | groupBy?: string; 50 | wheres?: WhereClause[]; 51 | whereIn?: WhereInClause; 52 | joins?: JoinClause[]; 53 | leftOuterJoins?: JoinClause[]; 54 | leftJoins?: JoinClause[]; 55 | aggregatorField?: string; 56 | limit?: number; 57 | offset?: number; 58 | ifExists?: boolean; 59 | fields?: ModelFields; 60 | fieldsDefaults?: ModelDefaults; 61 | timestamps?: boolean; 62 | values?: Values | Values[]; 63 | }; 64 | 65 | export type QueryResult = {}; 66 | 67 | export type Builder = typeof SQLQueryBuilder; 68 | 69 | /** Create queries descriptions. */ 70 | export class QueryBuilder { 71 | _query: QueryDescription = { schema: Model }; 72 | 73 | /** Create a fresh new query. */ 74 | queryForSchema(schema: ModelSchema): QueryBuilder { 75 | return new QueryBuilder().schema(schema); 76 | } 77 | 78 | schema(schema: ModelSchema) { 79 | this._query.schema = schema; 80 | return this; 81 | } 82 | 83 | toDescription(): QueryDescription { 84 | return this._query; 85 | } 86 | 87 | table(table: string) { 88 | this._query.table = table; 89 | return this; 90 | } 91 | 92 | get() { 93 | this._query.type = "select"; 94 | return this; 95 | } 96 | 97 | all() { 98 | return this.get(); 99 | } 100 | 101 | createTable( 102 | fields: ModelFields, 103 | fieldsDefaults: ModelDefaults, 104 | { 105 | withTimestamps, 106 | ifNotExists, 107 | }: { 108 | withTimestamps: boolean; 109 | ifNotExists: boolean; 110 | }, 111 | ) { 112 | this._query.type = "create"; 113 | this._query.ifExists = ifNotExists ? false : true; 114 | this._query.fields = fields; 115 | this._query.fieldsDefaults = fieldsDefaults; 116 | this._query.timestamps = withTimestamps; 117 | return this; 118 | } 119 | 120 | dropIfExists() { 121 | this._query.type = "drop"; 122 | this._query.ifExists = true; 123 | return this; 124 | } 125 | 126 | truncate() { 127 | this._query.type = "truncate"; 128 | return this; 129 | } 130 | 131 | select(...fields: (string | FieldAlias)[]) { 132 | this._query.select = fields; 133 | return this; 134 | } 135 | 136 | create(values: Values[]) { 137 | this._query.type = "insert"; 138 | this._query.values = values; 139 | return this; 140 | } 141 | 142 | find(field: string, possibleValues: FieldValue[]) { 143 | this._query.type = "select"; 144 | this._query.whereIn = { 145 | field, 146 | possibleValues, 147 | }; 148 | return this; 149 | } 150 | 151 | orderBy( 152 | field: string, 153 | orderDirection: OrderDirection, 154 | ) { 155 | if (!this._query.orderBy) { 156 | this._query.orderBy = {}; 157 | } 158 | this._query.orderBy[field] = orderDirection; 159 | return this; 160 | } 161 | 162 | groupBy(field: string) { 163 | this._query.groupBy = field; 164 | return this; 165 | } 166 | 167 | limit(limit: number) { 168 | this._query.limit = limit; 169 | return this; 170 | } 171 | 172 | offset(offset: number) { 173 | this._query.offset = offset; 174 | return this; 175 | } 176 | 177 | where( 178 | field: string, 179 | operator: Operator, 180 | value: FieldValue, 181 | ) { 182 | if (!this._query.wheres) { 183 | this._query.wheres = []; 184 | } 185 | 186 | const whereClause = { 187 | field, 188 | operator, 189 | value, 190 | }; 191 | 192 | const existingWhereForFieldIndex = this._query.wheres.findIndex((where) => 193 | where.field === field 194 | ); 195 | 196 | if (existingWhereForFieldIndex === -1) { 197 | this._query.wheres.push(whereClause); 198 | } else { 199 | this._query.wheres[existingWhereForFieldIndex] = whereClause; 200 | } 201 | 202 | return this; 203 | } 204 | 205 | update(values: Values) { 206 | this._query.type = "update"; 207 | this._query.values = values; 208 | return this; 209 | } 210 | 211 | delete() { 212 | this._query.type = "delete"; 213 | return this; 214 | } 215 | 216 | join( 217 | joinTable: string, 218 | originField: string, 219 | targetField: string, 220 | ) { 221 | if (!this._query.joins) { 222 | this._query.joins = []; 223 | } 224 | 225 | this._query.joins.push({ 226 | joinTable, 227 | originField, 228 | targetField, 229 | }); 230 | 231 | return this; 232 | } 233 | 234 | leftOuterJoin( 235 | joinTable: string, 236 | originField: string, 237 | targetField: string, 238 | ) { 239 | if (!this._query.leftOuterJoins) { 240 | this._query.leftOuterJoins = []; 241 | } 242 | 243 | this._query.leftOuterJoins.push({ 244 | joinTable, 245 | originField, 246 | targetField, 247 | }); 248 | 249 | return this; 250 | } 251 | 252 | leftJoin( 253 | joinTable: string, 254 | originField: string, 255 | targetField: string, 256 | ) { 257 | if (!this._query.leftJoins) { 258 | this._query.leftJoins = []; 259 | } 260 | 261 | this._query.leftJoins.push({ 262 | joinTable, 263 | originField, 264 | targetField, 265 | }); 266 | 267 | return this; 268 | } 269 | 270 | count(field: string) { 271 | this._query.type = "count"; 272 | this._query.aggregatorField = field; 273 | return this; 274 | } 275 | 276 | min(field: string) { 277 | this._query.type = "min"; 278 | this._query.aggregatorField = field; 279 | return this; 280 | } 281 | 282 | max(field: string) { 283 | this._query.type = "max"; 284 | this._query.aggregatorField = field; 285 | return this; 286 | } 287 | 288 | sum(field: string) { 289 | this._query.type = "sum"; 290 | this._query.aggregatorField = field; 291 | return this; 292 | } 293 | 294 | avg(field: string) { 295 | this._query.type = "avg"; 296 | this._query.aggregatorField = field; 297 | return this; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /lib/relationships.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSchema } from "./model.ts"; 2 | import { DataTypes, RelationshipType } from "./data-types.ts"; 3 | import { PivotModel } from "./model-pivot.ts"; 4 | 5 | type PrimaryKeyOption = { 6 | primaryKey?: string; 7 | }; 8 | 9 | type ForeignKeyOption = { 10 | foreignKey?: string; 11 | }; 12 | 13 | type RelationshipOptions = PrimaryKeyOption & ForeignKeyOption; 14 | 15 | export const Relationships = { 16 | /** Define a one-to-one or one-to-many relationship field for a given model. */ 17 | _belongsToField(model: ModelSchema): RelationshipType { 18 | return { 19 | type: DataTypes.INTEGER, 20 | relationship: { 21 | kind: "single", 22 | model, 23 | }, 24 | }; 25 | }, 26 | 27 | /** Define a one-to-one or one-to-many relationship for a given model. */ 28 | belongsTo( 29 | modelA: ModelSchema, 30 | modelB: ModelSchema, 31 | options?: ForeignKeyOption, 32 | ) { 33 | const foreignKey = options?.foreignKey; 34 | const modelAFieldName = foreignKey || `${modelB.name.toLowerCase()}Id`; 35 | modelA.fields[modelAFieldName] = this._belongsToField(modelB); 36 | }, 37 | 38 | /** Add corresponding fields to each model for a one-to-one relationship. */ 39 | oneToOne( 40 | modelA: ModelSchema, 41 | modelB: ModelSchema, 42 | options?: RelationshipOptions, 43 | ) { 44 | const primaryKey = options?.primaryKey; 45 | const foreignKey = options?.foreignKey; 46 | 47 | const modelAFieldName = primaryKey || `${modelB.name.toLowerCase()}Id`; 48 | const modelBFieldName = foreignKey || `${modelA.name.toLowerCase()}Id`; 49 | 50 | modelA.fields[modelAFieldName] = this._belongsToField(modelB); 51 | modelB.fields[modelBFieldName] = this._belongsToField(modelA); 52 | }, 53 | 54 | /** Generate a many-to-many pivot model for two given models. 55 | * 56 | * const AirportFlight = Relationships.manyToMany(Airport, Flight); 57 | */ 58 | manyToMany( 59 | modelA: ModelSchema, 60 | modelB: ModelSchema, 61 | options?: RelationshipOptions, 62 | ): ModelSchema { 63 | const primaryKey = options?.primaryKey; 64 | const foreignKey = options?.foreignKey; 65 | 66 | const pivotClassName = `${modelA.table}_${modelB.table}`; 67 | const modelAFieldName = primaryKey || `${modelA.name.toLowerCase()}Id`; 68 | const modelBFieldName = foreignKey || `${modelB.name.toLowerCase()}Id`; 69 | 70 | class PivotClass extends PivotModel { 71 | static table = pivotClassName; 72 | 73 | static fields = { 74 | id: { 75 | primaryKey: true, 76 | autoIncrement: true, 77 | }, 78 | [modelAFieldName]: Relationships._belongsToField(modelA), 79 | [modelBFieldName]: Relationships._belongsToField(modelB), 80 | }; 81 | 82 | static _pivotsModels = { 83 | [modelA.name]: modelA, 84 | [modelB.name]: modelB, 85 | }; 86 | 87 | static _pivotsFields = { 88 | [modelA.name]: modelAFieldName, 89 | [modelB.name]: modelBFieldName, 90 | }; 91 | } 92 | 93 | modelA.pivot[modelB.name] = PivotClass; 94 | modelB.pivot[modelA.name] = PivotClass; 95 | 96 | return PivotClass; 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /lib/translators/basic-translator.ts: -------------------------------------------------------------------------------- 1 | import { Query, QueryDescription } from "../query-builder.ts"; 2 | import { FieldAlias } from "../data-types.ts"; 3 | import { Translator } from "./translator.ts"; 4 | 5 | export class BasicTranslator implements Translator { 6 | /** Translate a query description into a regular query. */ 7 | translateToQuery(query: QueryDescription): Query { 8 | return ""; 9 | } 10 | 11 | /** Format a field to the database format, e.g. `userName` to `user_name`. */ 12 | formatFieldNameToDatabase( 13 | fieldName: string | FieldAlias, 14 | ): string | FieldAlias { 15 | return fieldName; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/translators/sql-translator.ts: -------------------------------------------------------------------------------- 1 | import { snakeCase, SQLQueryBuilder } from "../../deps.ts"; 2 | import type { FieldAlias } from "../data-types.ts"; 3 | import { addFieldToSchema } from "../helpers/fields.ts"; 4 | import type { Query, QueryDescription } from "../query-builder.ts"; 5 | import type { Translator } from "./translator.ts"; 6 | 7 | // Supported Database dialects for the SQLQueryBuilder in use 8 | // @see http://knexjs.org 9 | export type SupportedSQLDatabaseDialect = 10 | | "mysql" 11 | | "mysql2" 12 | | "oracledb" 13 | | "postgres" 14 | | "redshift" 15 | | "sqlite3" 16 | | "mssql"; 17 | 18 | export class SQLTranslator implements Translator { 19 | _dialect: SupportedSQLDatabaseDialect; 20 | 21 | constructor(dialect: SupportedSQLDatabaseDialect) { 22 | this._dialect = dialect; 23 | } 24 | 25 | translateToQuery(query: QueryDescription): Query { 26 | let queryBuilder = new (SQLQueryBuilder as any)( 27 | { 28 | client: this._dialect, 29 | useNullAsDefault: this._dialect === "sqlite3", 30 | log: { 31 | // NOTE(eveningkid): It shows a message whenever `createTableIfNotExists` 32 | // is used. Knex deprecated it as part of its library but in our case, 33 | // it actually makes sense. As this warning message should be ignored, 34 | // we override the `log.warn` method so it doesn't show up. 35 | warn() { 36 | }, 37 | }, 38 | }, 39 | ); 40 | 41 | if (query.table && query.type !== "drop" && query.type !== "create") { 42 | queryBuilder = queryBuilder.table(query.table); 43 | } 44 | 45 | if (query.select) { 46 | query.select.forEach((field) => { 47 | queryBuilder = queryBuilder.select(field); 48 | }); 49 | } 50 | 51 | if (query.whereIn) { 52 | queryBuilder = queryBuilder.whereIn( 53 | query.whereIn.field, 54 | query.whereIn.possibleValues, 55 | ); 56 | } 57 | 58 | if (query.orderBy) { 59 | queryBuilder = queryBuilder.orderBy( 60 | Object.entries(query.orderBy).map(([field, orderDirection]) => ({ 61 | column: field, 62 | order: orderDirection, 63 | })), 64 | ); 65 | } 66 | 67 | if (query.groupBy) { 68 | queryBuilder = queryBuilder.groupBy(query.groupBy); 69 | } 70 | 71 | if (query.limit) { 72 | queryBuilder = queryBuilder.limit(query.limit); 73 | } 74 | 75 | if (query.offset) { 76 | queryBuilder = queryBuilder.offset(query.offset); 77 | } 78 | 79 | if (query.wheres) { 80 | query.wheres.forEach((where) => { 81 | queryBuilder = queryBuilder.where( 82 | where.field, 83 | where.operator, 84 | where.value, 85 | ); 86 | }); 87 | } 88 | 89 | if (query.joins) { 90 | query.joins.forEach((join) => { 91 | queryBuilder = queryBuilder.join( 92 | join.joinTable, 93 | join.originField, 94 | "=", 95 | join.targetField, 96 | ); 97 | }); 98 | } 99 | 100 | if (query.leftOuterJoins) { 101 | query.leftOuterJoins.forEach((join) => { 102 | queryBuilder = queryBuilder.leftOuterJoin( 103 | join.joinTable, 104 | join.originField, 105 | join.targetField, 106 | ); 107 | }); 108 | } 109 | 110 | if (query.leftJoins) { 111 | query.leftJoins.forEach((join) => { 112 | queryBuilder = queryBuilder.leftJoin( 113 | join.joinTable, 114 | join.originField, 115 | join.targetField, 116 | ); 117 | }); 118 | } 119 | 120 | switch (query.type) { 121 | case "drop": 122 | const dropTableHelper = query.ifExists 123 | ? "dropTableIfExists" 124 | : "dropTable"; 125 | 126 | queryBuilder = queryBuilder.schema[dropTableHelper](query.table); 127 | break; 128 | 129 | case "truncate": 130 | queryBuilder = queryBuilder.truncate(query.table); 131 | break; 132 | 133 | case "create": 134 | if (!query.fields) { 135 | throw new Error( 136 | "Fields were not provided for creating a new instance.", 137 | ); 138 | } 139 | 140 | const createTableHelper = query.ifExists 141 | ? "createTable" 142 | : "createTableIfNotExists"; 143 | 144 | queryBuilder = queryBuilder.schema[createTableHelper]( 145 | query.table, 146 | (table: any) => { 147 | const fieldDefaults = query.fieldsDefaults ?? {}; 148 | 149 | for ( 150 | const [field, fieldType] of Object.entries(query.fields!) 151 | ) { 152 | addFieldToSchema( 153 | table, 154 | { 155 | name: field, 156 | type: fieldType, 157 | defaultValue: fieldDefaults[field], 158 | }, 159 | ); 160 | } 161 | 162 | if (query.timestamps) { 163 | // Adds `createdAt` and `updatedAt` fields 164 | table.timestamps(null, true); 165 | } 166 | }, 167 | ); 168 | break; 169 | 170 | case "insert": 171 | if (!query.values) { 172 | throw new Error( 173 | "Trying to make an insert query, but no values were provided.", 174 | ); 175 | } 176 | 177 | queryBuilder = queryBuilder.returning("*").insert(query.values); 178 | break; 179 | 180 | case "update": 181 | if (!query.values) { 182 | throw new Error( 183 | "Trying to make an update query, but no values were provided.", 184 | ); 185 | } 186 | 187 | queryBuilder = queryBuilder.update(query.values); 188 | break; 189 | 190 | case "delete": 191 | queryBuilder = queryBuilder.del(); 192 | break; 193 | 194 | case "count": 195 | queryBuilder = queryBuilder.count( 196 | query.aggregatorField ? query.aggregatorField : "*", 197 | ); 198 | break; 199 | 200 | case "avg": 201 | queryBuilder = queryBuilder.avg( 202 | query.aggregatorField ? query.aggregatorField : "*", 203 | ); 204 | break; 205 | 206 | case "min": 207 | queryBuilder = queryBuilder.min( 208 | query.aggregatorField ? query.aggregatorField : "*", 209 | ); 210 | break; 211 | 212 | case "max": 213 | queryBuilder = queryBuilder.max( 214 | query.aggregatorField ? query.aggregatorField : "*", 215 | ); 216 | break; 217 | 218 | case "sum": 219 | queryBuilder = queryBuilder.sum( 220 | query.aggregatorField ? query.aggregatorField : "*", 221 | ); 222 | break; 223 | } 224 | 225 | return queryBuilder.toString(); 226 | } 227 | 228 | formatFieldNameToDatabase( 229 | fieldName: string | FieldAlias, 230 | ): string | FieldAlias { 231 | if (typeof fieldName === "string") { 232 | const dotIndex = fieldName.indexOf("."); 233 | 234 | // Table.fieldName 235 | if (dotIndex !== -1) { 236 | return fieldName.slice(0, dotIndex + 1) + 237 | snakeCase(fieldName.slice(dotIndex + 1)); 238 | } 239 | 240 | return snakeCase(fieldName); 241 | } else { 242 | return Object.entries(fieldName).reduce( 243 | (prev: any, [alias, fullName]) => { 244 | prev[alias] = this.formatFieldNameToDatabase(fullName); 245 | return prev; 246 | }, 247 | {}, 248 | ); 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /lib/translators/translator.ts: -------------------------------------------------------------------------------- 1 | import type { Query, QueryDescription } from "../query-builder.ts"; 2 | import type { FieldAlias } from "../data-types.ts"; 3 | 4 | /** Translator interface for translating `QueryDescription` objects to regular queries. */ 5 | export interface Translator { 6 | /** Translate a query description into a regular query. */ 7 | translateToQuery(query: QueryDescription): Query; 8 | 9 | /** Format a field to the database format, e.g. `userName` to `user_name`. */ 10 | formatFieldNameToDatabase( 11 | fieldName: string | FieldAlias, 12 | ): string | FieldAlias; 13 | } 14 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { DATA_TYPES, DataTypes } from "./lib/data-types.ts"; 2 | export { Database } from "./lib/database.ts"; 3 | export type { DatabaseOptions, SyncOptions } from "./lib/database.ts"; 4 | export { Model } from "./lib/model.ts"; 5 | export { Relationships } from "./lib/relationships.ts"; 6 | export type { Connector } from "./lib/connectors/connector.ts"; 7 | export { MongoDBConnector } from "./lib/connectors/mongodb-connector.ts"; 8 | export type { MongoDBOptions } from "./lib/connectors/mongodb-connector.ts"; 9 | export { MySQLConnector } from "./lib/connectors/mysql-connector.ts"; 10 | export type { MySQLOptions } from "./lib/connectors/mysql-connector.ts"; 11 | export { PostgresConnector } from "./lib/connectors/postgres-connector.ts"; 12 | export type { PostgresOptions } from "./lib/connectors/postgres-connector.ts"; 13 | export { SQLite3Connector } from "./lib/connectors/sqlite3-connector.ts"; 14 | export type { SQLite3Options } from "./lib/connectors/sqlite3-connector.ts"; 15 | export { connectorFactory } from "./lib/connectors/factory.ts"; 16 | -------------------------------------------------------------------------------- /tests/connection.ts: -------------------------------------------------------------------------------- 1 | import { config } from "https://deno.land/x/dotenv/mod.ts"; 2 | import { Database } from "../mod.ts"; 3 | 4 | const env = config(); 5 | 6 | const defaultMySQLOptions = { 7 | database: "test", 8 | host: "127.0.0.1", 9 | username: env.DB_USER, 10 | password: env.DB_PASS, 11 | port: Number(env.DB_PORT), 12 | }; 13 | 14 | const defaultSQLiteOptions = { 15 | filepath: "test.sqlite", 16 | }; 17 | 18 | const getMySQLConnection = (options = {}, debug = true): Database => { 19 | const connection: Database = new Database( 20 | { dialect: "mysql", debug }, 21 | { 22 | ...defaultMySQLOptions, 23 | ...options, 24 | }, 25 | ); 26 | 27 | return connection; 28 | }; 29 | 30 | const getSQLiteConnection = (options = {}, debug = true): Database => { 31 | const connection: Database = new Database( 32 | { dialect: "sqlite3", debug }, 33 | { 34 | ...defaultSQLiteOptions, 35 | ...options, 36 | }, 37 | ); 38 | 39 | return connection; 40 | }; 41 | 42 | export { getMySQLConnection, getSQLiteConnection }; 43 | -------------------------------------------------------------------------------- /tests/deps.ts: -------------------------------------------------------------------------------- 1 | export { assertEquals } from "https://deno.land/std@0.115.1/testing/asserts.ts"; 2 | -------------------------------------------------------------------------------- /tests/units/Relationships/foreignkey.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, Relationships } from "../../../mod.ts"; 2 | import { getMySQLConnection } from "../../connection.ts"; 3 | import { assertEquals } from "../../deps.ts"; 4 | 5 | class Owner extends Model { 6 | static table = "foreignkeyowners"; 7 | static timestamps = false; 8 | 9 | static fields = { 10 | id: { 11 | type: DataTypes.INTEGER, 12 | primaryKey: true, 13 | }, 14 | name: DataTypes.STRING, 15 | }; 16 | } 17 | 18 | class Business extends Model { 19 | static table = "foreignkeybusinesses"; 20 | static timestamps = false; 21 | 22 | static fields = { 23 | id: { 24 | type: DataTypes.INTEGER, 25 | primaryKey: true, 26 | }, 27 | name: DataTypes.STRING, 28 | }; 29 | 30 | static owner() { 31 | return this.hasOne(Owner); 32 | } 33 | } 34 | 35 | Relationships.belongsTo(Business, Owner); 36 | 37 | Deno.test("MySQL: Foreign key test", async function () { 38 | const connection = getMySQLConnection(); 39 | 40 | connection.link([Owner, Business]); 41 | 42 | await connection.sync({ drop: false }); 43 | 44 | await Owner.create({ 45 | id: "1", 46 | name: "John", 47 | }); 48 | 49 | await Business.create({ 50 | id: "1", 51 | name: "Parisian Café", 52 | ownerId: "1", 53 | }); 54 | 55 | const OwnerTest = await Business.where("id", "1").owner(); 56 | 57 | await connection.close(); 58 | 59 | assertEquals( 60 | JSON.stringify(OwnerTest), 61 | JSON.stringify({ 62 | id: 1, 63 | name: "John", 64 | }), 65 | ); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/units/connectors/mysql/connection.test.ts: -------------------------------------------------------------------------------- 1 | import { getMySQLConnection } from "../../../connection.ts"; 2 | import { assertEquals } from "../../../deps.ts"; 3 | 4 | Deno.test("MySQL: Connection", async function () { 5 | const connection = getMySQLConnection(); 6 | const ping = await connection.ping(); 7 | await connection.close(); 8 | 9 | assertEquals(ping, true); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/units/connectors/mysql/models.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from "../../../../mod.ts"; 2 | import { getMySQLConnection } from "../../../connection.ts"; 3 | import { assertEquals } from "../../../deps.ts"; 4 | 5 | Deno.test("MySQL: Single model", async function () { 6 | const connection = getMySQLConnection(); 7 | 8 | class Flight extends Model { 9 | static table = "flights"; 10 | static timestamps = false; 11 | 12 | static fields = { 13 | id: { primaryKey: true, autoIncrement: true }, 14 | departure: DataTypes.STRING, 15 | destination: DataTypes.STRING, 16 | flightDuration: DataTypes.FLOAT, 17 | }; 18 | 19 | static defaults = { 20 | flightDuration: 2.5, 21 | }; 22 | } 23 | 24 | connection.link([Flight]); 25 | 26 | await connection.sync({ drop: false }); 27 | 28 | await Flight.create({ 29 | departure: "Paris", 30 | destination: "Tokyo", 31 | }); 32 | 33 | const result = await Flight.where({ departure: "Paris" }).first(); 34 | 35 | await connection.close(); 36 | 37 | assertEquals( 38 | JSON.stringify(result), 39 | JSON.stringify({ 40 | id: 1, 41 | departure: "Paris", 42 | destination: "Tokyo", 43 | flightDuration: 2.5, 44 | }), 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/units/queries/sqlite/insert.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from "../../../../mod.ts"; 2 | import { getSQLiteConnection } from "../../../connection.ts"; 3 | import { assertEquals } from "../../../deps.ts"; 4 | 5 | class Article extends Model { 6 | static table = "updatearticle"; 7 | static timestamps = false; 8 | 9 | static fields = { 10 | id: { 11 | type: DataTypes.INTEGER, 12 | primaryKey: true, 13 | autoIncrement: true, 14 | }, 15 | title: DataTypes.STRING, 16 | content: DataTypes.TEXT, 17 | }; 18 | } 19 | 20 | Deno.test("SQLite: Insert model", async () => { 21 | const connection = getSQLiteConnection(); 22 | 23 | connection.link([Article]); 24 | 25 | await connection.sync({ drop: true }); 26 | 27 | await Article.create({ 28 | title: "Hello world!", 29 | content: "first article!", 30 | }); 31 | 32 | const article = await Article.where({ id: 1 }).first(); 33 | 34 | await connection.close(); 35 | 36 | assertEquals( 37 | JSON.stringify(article), 38 | JSON.stringify({ 39 | id: 1, 40 | title: "Hello world!", 41 | content: "first article!", 42 | }), 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/units/queries/sqlite/response.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from "../../../../mod.ts"; 2 | import { getSQLiteConnection } from "../../../connection.ts"; 3 | import { assertEquals } from "../../../deps.ts"; 4 | 5 | class Article extends Model { 6 | static table = "updatearticle"; 7 | static timestamps = false; 8 | 9 | static fields = { 10 | id: { 11 | type: DataTypes.INTEGER, 12 | primaryKey: true, 13 | autoIncrement: true, 14 | }, 15 | title: DataTypes.STRING, 16 | content: DataTypes.TEXT, 17 | }; 18 | } 19 | 20 | Deno.test("SQLite: Response model", async () => { 21 | const connection = getSQLiteConnection(); 22 | 23 | connection.link([Article]); 24 | 25 | await connection.sync({ drop: true }); 26 | 27 | const createResponse = await Article.create({ 28 | title: "Hello world!", 29 | content: "first article!", 30 | }); 31 | 32 | assertEquals( 33 | JSON.stringify(createResponse), 34 | JSON.stringify({ 35 | affectedRows: 1, 36 | lastInsertId: 1, 37 | }), 38 | "Insert response", 39 | ); 40 | 41 | const selectResponse = await Article.select("id") 42 | .where({ title: "Hello world!" }).get(); 43 | 44 | assertEquals( 45 | JSON.stringify(selectResponse), 46 | JSON.stringify([{ id: 1 }]), 47 | "Expected one article", 48 | ); 49 | 50 | const updateResponse = await Article.where({ id: 1 }) 51 | .update({ title: "Hello there!" }); 52 | 53 | assertEquals( 54 | JSON.stringify(updateResponse), 55 | JSON.stringify({ affectedRows: 1 }), 56 | "Update response", 57 | ); 58 | 59 | const deleteResponse = await Article.deleteById(1); 60 | 61 | assertEquals( 62 | JSON.stringify(deleteResponse), 63 | JSON.stringify({ affectedRows: 1 }), 64 | "Delete response", 65 | ); 66 | 67 | const createManyResponse = await Article.create([ 68 | { title: "hola mundo!", content: "primer articulo!" }, 69 | { title: "hola mundo!", content: "first article!" }, 70 | ]); 71 | 72 | assertEquals( 73 | JSON.stringify(createManyResponse), 74 | JSON.stringify({ affectedRows: 2, lastInsertId: 3 }), 75 | "Insert many records response", 76 | ); 77 | 78 | const updateManyResponse = await Article.where({ title: "hola mundo!" }) 79 | .update({ content: "updated" }); 80 | 81 | assertEquals( 82 | JSON.stringify(updateManyResponse), 83 | JSON.stringify({ affectedRows: 2 }), 84 | "Update many records response", 85 | ); 86 | 87 | const articleCount = await Article.where({ title: "hola mundo!" }).count(); 88 | 89 | assertEquals(articleCount, 2, "Return article count"); 90 | 91 | const deleteManyResponse = await Article.where({ title: "hola mundo!" }) 92 | .delete(); 93 | 94 | assertEquals( 95 | JSON.stringify(deleteManyResponse), 96 | JSON.stringify({ affectedRows: 2 }), 97 | "Delete many records response", 98 | ); 99 | 100 | const selectEmptyResponse = await Article.all(); 101 | 102 | assertEquals( 103 | JSON.stringify(selectEmptyResponse), 104 | JSON.stringify([]), 105 | "Select expected empty response", 106 | ); 107 | 108 | await connection.close(); 109 | }); 110 | -------------------------------------------------------------------------------- /tests/units/queries/sqlite/update.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from "../../../../mod.ts"; 2 | import { getSQLiteConnection } from "../../../connection.ts"; 3 | import { assertEquals } from "../../../deps.ts"; 4 | 5 | class Article extends Model { 6 | static table = "updatearticle"; 7 | static timestamps = false; 8 | 9 | static fields = { 10 | id: { 11 | type: DataTypes.INTEGER, 12 | primaryKey: true, 13 | autoIncrement: true, 14 | }, 15 | title: DataTypes.STRING, 16 | content: DataTypes.TEXT, 17 | }; 18 | } 19 | 20 | Deno.test("SQLite: Update model", async () => { 21 | const connection = getSQLiteConnection(); 22 | 23 | connection.link([Article]); 24 | 25 | await connection.sync({ drop: true }); 26 | 27 | await Article.create({ 28 | title: "Hello world!", 29 | content: "first articlE!", 30 | }); 31 | 32 | await Article.where({ id: 1 }).update({ content: "first article!" }); 33 | 34 | const article = await Article.where({ id: 1 }).first(); 35 | 36 | await connection.close(); 37 | 38 | assertEquals( 39 | JSON.stringify(article), 40 | JSON.stringify({ 41 | id: 1, 42 | title: "Hello world!", 43 | content: "first article!", 44 | }), 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/units/queries/update.test.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from "../../../mod.ts"; 2 | import { getMySQLConnection } from "../../connection.ts"; 3 | import { assertEquals } from "../../deps.ts"; 4 | 5 | class Article extends Model { 6 | static table = "updatearticle"; 7 | static timestamps = false; 8 | 9 | static fields = { 10 | id: { 11 | type: DataTypes.INTEGER, 12 | primaryKey: true, 13 | autoIncrement: true, 14 | }, 15 | title: DataTypes.STRING, 16 | content: DataTypes.TEXT, 17 | }; 18 | } 19 | 20 | Deno.test("MySQL: Update model", async function () { 21 | const connection = getMySQLConnection(); 22 | 23 | connection.link([Article]); 24 | 25 | await connection.sync({ drop: false }); 26 | 27 | await Article.create({ 28 | title: "Hello world!", 29 | content: "first articlE!", 30 | }); 31 | 32 | await Article.where({ id: 1 }).update({ content: "first article!" }); 33 | 34 | const article = await Article.where({ id: 1 }).first(); 35 | 36 | await connection.close(); 37 | 38 | assertEquals( 39 | JSON.stringify(article), 40 | JSON.stringify({ 41 | id: 1, 42 | title: "Hello world!", 43 | content: "first article!", 44 | }), 45 | ); 46 | }); 47 | --------------------------------------------------------------------------------