├── .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 |
--------------------------------------------------------------------------------