mingru-models
Version:
Strongly typed database models in TypeScript
880 lines (650 loc) • 25.5 kB
Markdown
# mingru-models (WIP)
[](https://github.com/mgenware/mingru-models/actions)
[](https://github.com/mgenware/MEAN-Module)
[](https://npmjs.com/package/mingru-models)
[](https://nodejs.org/en/)
Strongly typed database models in TypeScript.
**All APIs are subject to change before 1.0.0**
**Note that mingru-models only helps you define database models in a strong-typed way, it has nothing to do with how these models are going to be used, usually, you use other libraries to consume your models**, examples:
- [mingru](https://github.com/mgenware/mingru) converts mingru-models to Go code
---
# NOTE: The documentation below is outdated!!! We are working on a brand new documentation site. Stay tuned.
---
## Installation
```sh
yarn install mingru-models
```
# Quick Start
## Defining Models
### Tables
To create a table:
1. Create a class inheriting from `mm.Table`.
2. Add table columns as instance properties.
3. Export a table object via `mm.table`.
For example, a table named `User` with 2 columns, `id` and `name`:
```ts
import * as mm from 'mingru-models';
class User extends mm.Table {
id = mm.pk();
name = mm.varChar(100);
}
export default mm.table(User);
```
You may wonder why a two-step process, why not export the table type directly? well, there are several reasons:
- Exporting the class would require user to define columns as `static` properties.
- When calling `mm.table`, mingru-models will look through all columns and do some validation as well as preprocessing work like setting up foreign keys, which is definitely suited for an object.
#### Table Name
By default, class name is used as table name, and mingru-models automatically converts it to snake_case in SQL, for example, `class MyTable` would be `my_table`. Use the second parameter of `mm.table` to customize the name used in SQL.
```ts
class MyTable extends mm.Table {}
export default mm.table(MyTable); // Table name is "my_table" in SQL.
export default mm.table(MyTable, 'haha'); // Table name is "haha" in SQL.
```
### Columns
#### Column Helper Methods
Columns are nothing but `mm.Column` objects, but we actually seldom need to manually create columns by `new mm.Column(...)`. Instead, we use column helper methods to create commonly used columns.
For example:
```ts
// `id` is a primary key, data type defaults to unsigned `BIGINT`.
id = mm.pk();
// `age` is `INT`.
age = mm.int();
// `name` is `VARCHAR(100)`, and defaults to 'abc'.
name = mm.varChar(100).default('abc');
// Set primary key underlying data type to `INT`.
id = mm.pk(mm.int());
```
For date time columns, you can also make them default to a `NOW()` value.
```ts
datetime_updated = mm.datetime('local'); // Defaults to local time NOW().
date_updated mm.date('utc'); // Defaults to UTC NOW().
time_updated = mm.time('utc'); // Defaults to UTC NOW().
```
Sometimes, we need to fully customize a default value, e.g. an SQL expression, you can always call `Column.default` and pass an SQL expression (will be covered in [Raw SQL Expressions](#where-and-raw-sql-expressions) below):
```ts
// Set it to an custom SQL expression once inserted.
age = mm.int().default(mm.sql`FLOOR(RAND() * 401) + 100`);
// These two lines are equivalent.
datetime_updated = mm.datetime('utc');
datetime_updated = mm.datetime().default(mm.sql`${mm.utcDatetimeNow()}`);
```
Here is a full list of column creation helper methods:
```ts
// Primary key
function pk(column?: Column): Column;
// Foreign key
function fk(column: Column): Column;
// VARCHAR column
function varChar(length: number): Column;
// CHAR column
function char(length: number): Column;
// INT column
function int(length?: number): Column;
// unsigned INT column
function uInt(length?: number | null): Column;
// BIGINT column
function bigInt(length?: number | null): Column;
// unsigned BIGINT column
function uBigInt(length?: number | null): Column;
// SMALLINT column
function smallInt(length?: number | null): Column;
// unsigned SMALLINT column
function uSmallInt(length?: number | null): Column;
// TINYINT column
function tinyInt(length?: number | null): Column;
// unsigned TINYINT column
function uTinyInt(length?: number | null): Column;
// FLOAT column
function float(precision?: number | null): Column;
// DOUBLE column
function double(precision?: number | null): Column;
// Adds UNIQUE constraint to a column
function unique(col: Column): Column;
// TEXT column
function text(): Column;
// BOOL column
function bool(): Column;
// DATETIME column
function datetime(defaultsToNow?: DateTimeDefaultValue): Column;
// DATE column
function date(defaultsToNow?: DateTimeDefaultValue): Column;
// TIME column
function time(defaultsToNow?: DateTimeDefaultValue): Column;
// TIMESTAMP column
function timestamp(defaultsToNow?: boolean): Column;
// VARBINARY column
function varBinary(length: number): Column;
// BINARY column
function binary(length: number): Column;
export type DateTimeDefaultValue = 'none' | 'local' | 'utc';
```
NOTE: columns created by column helper methods are **`NOT NULL`** by default, to create nullable (`NULL`) column, use the extra `nullable` property:
```ts
name = mm.varChar(100); // `name` cannot be NULL
sig1 = mm.text().nullable; // `sig1` can be NULL
sig2 = mm.text(null).nullable; // `sig2` can be NULL and also defaults to NULL
```
#### Column Name
By default, property name reflects the column name, if you need a different name from property name, use `Column.setDBName` method:
```ts
// Column name defaults to property name: "cmt_count"
cmt_count = mm.varChar(100);
// Column name is now "cmt_c"
cmt_count = mm.varChar(100).setDBName('cmt_c');
```
#### Column Objects
You can create column objects manually if column helper methods don't fit your needs, a column object consists of a bunch of properties describing different traits of a column.
```ts
class ColumnType {
types: string[];
pk: boolean;
nullable: boolean;
unsigned: boolean;
unique: boolean;
length: number;
constructor(types: string | string[]);
}
class Column extends ColumnBase {
type: ColumnType;
}
```
<TODO: Add example code>
### Foreign keys
Foreign keys can be created by simply assigning an imported column to another column, for example, let's say `post` table has a foreign key to `user` table at `user_id` column, here is what `user` looks like (`user.ts`):
```ts
import * as mm from 'mingru-models';
class User extends mm.Table {
id = mm.pk();
name = mm.varChar(100);
}
export default mm.table(User);
```
To create a foreign key to `user` table, inside `post` table (`post.ts`), you need to import `user` table, and set `user.id` to the `user_id` column:
```ts
import * as mm from 'mingru-models';
import user from './user';
class Post extends mm.Table {
id = mm.pk();
user_id = user.id; // `post.user_id` now references `user.id`
}
export default mm.table(Post);
```
## Actions
### Overview
Similar to defining a table, to define table actions, we need declare a class inheriting from `mm.ActionGroup` (**TA** stands for **t**able **a**ctions), and define actions as properties, finally export a single table actions object via `mm.ta`.
```ts
// Import the underlying table object
import user from './user';
// --- userTA.ts ---
export class UserTA extends mm.ActionGroup {
// Selects all users
selectAllUsers = mm.selectRows(user.id, user.name);
// Selects a single user by ID
selectUser = mm.selectRow(user.id, user.name).byID();
// Updates an user by ID
updateUser = dd.updateOne().setInputs(user.name, user.sig).byID();
// Delete an user by ID
deleteUser = mm.deleteOne().byID();
}
// Export a table actions object
export default mm.actionGroup(user, UserTA);
```
### `SELECT` Actions Basics
mingru-models supports the following kinds of `SELECT` actions:
```ts
// Selects a row.
function select(...columns: ColumnBase[]): SelectAction;
// Selects rows.
function selectRows(...columns: ColumnBase[]): SelectAction;
// Selects a single field of a specific row.
function selectField(column: ColumnBase): SelectAction;
```
They differ from return values:
- `select` returns an row object containing all selected columns
- `selectRows` returns an array of row objects each containing all selected columns
- `selectField` returns the single selected field
For example, in [mingru](https://github.com/mgenware/mingru), consider the following models and actions:
```ts
// ----------- user table model (user.ts) -----------
import * as mm from 'mingru-models';
class User extends mm.Table {
id = mm.pk();
name = mm.varChar(100);
sig = mm.text().nullable;
}
export default mm.table(User);
// ----------- user table actions (userTA.ts) -----------
import user from './user';
export class UserTA extends mm.ActionGroup {
// Select a user profile by ID.
selectProfile = mm.selectRow(user.id, user.name, user.sig).byID();
// Select all user profiles.
selectAllProfiles = mm.selectRows(user.id, user.name, user.sig);
// Select the sig field by ID.
selectSig = mm.selectField(user.sig).byID();
}
export default mm.actionGroup(user, UserTA);
```
It would generate the following Go code (only function headers shown for simplicity):
```go
// SelectUserProfile ...
func (da *TableTypeUser) SelectProfile(queryable dbx.Queryable, userID uint64) (*SelectUserProfileResult, error)
// SelectAllUserProfiles ...
func (da *TableTypeUser) SelectAllProfiles(queryable dbx.Queryable) ([]*SelectAllUserProfilesResult, error)
// SelectSig ...
func (da *TableTypeUser) SelectSig(queryable dbx.Queryable, userID uint64) (*string, error)
```
#### `SELECT *`
To select all columns of a table, simply call `select` or `selectRows` with no arguments.
### `WHERE` and Raw SQL Expressions
We haven't used any `WHERE` clause in the `SELECT` actions above, to add a `WHERE` clause, we have to construct a raw SQL expression using `mm.sql`, which uses TypeScript/JavaScript template string and enables us to write arbitrary SQL expressions.
You can pass a column object to template string, it will be converted to a column name in SQL, for example:
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${user.id} = 1`;
```
[mingru](https://github.com/mgenware/mingru) translates this into:
```sql
SELECT `id`, `name`, `sig` FROM `user` WHERE `id` = 1
```
More complex queries:
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig)
.where`${user.id} = 1 AND ${user.sig} <> 'haha'`;
```
[mingru](https://github.com/mgenware/mingru) translates this into:
```sql
SELECT `id`, `name`, `sig` FROM `user` WHERE `id` = 1 AND `sig` <> 'haha'
```
#### Input Parameters
Your actions often require user input parameters, e.g. to select a single profile from user table, we need a `id` parameter which can uniquely identify an user record. Use `mm.input` for this purpose:
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${user.id} = ${mm.input(
user.id,
)}`;
```
[mingru](https://github.com/mgenware/mingru) translates this to the following Go code:
```go
func (da *TableTypeUser) SelectUserProfile(queryable dbx.Queryable, id uint64) (*UserTableSelectUserProfileResult, error) {
result := &UserTableSelectUserProfileResult{}
err := queryable.QueryRow("SELECT `id`, `name`, `sig` FROM `user` WHERE `id` = ?", id).Scan(&result.ID, &result.Name, &result.Sig)
if err != nil {
return nil, err
}
return result, nil
}
```
The `mm.input(user.id)` instructs builder to include a parameter named `id` and pass it to SQL query function. If you don't like the auto inferred name, can use the second optional `name` argument of `mm.input`:
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${user.id} = ${mm.input(
user.id,
'uid',
)}`;
// Now input name is `uid` instead of `name`
```
The auto inferred name also differs on foreign column, it uses full column name on foreign column:
```ts
mm.input(post.id);
// Input name is id
mm.input(comment.post_id.join(post).title);
// Input name is postTitle instead of title because title comes from a joined table
```
#### SQL Expression Helpers
Writing `mm.input`s in `mm.sql` can be tedious, mingru-models comes with a bunch of handy helpers to construct some commonly used expressions.
##### `Column.toInput(column, optionalName): SQLVariable`
Shortcut to `mm.input(column, optionalName)`:
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${
user.id
} = ${user.id.toInput()}`;
```
##### `Column.isEqualTo(sql): SQL`
```ts
selectUserProfile = dd.select(user.id, user.name, user.sig).whereSQL(user.name.isEqualTo`"Admin"`);
```
Is equivalent to:
```ts
selectProfile = dd.select(user.id, user.name, user.sig).where`${user.name} = "Admin"`;
```
##### `Column.isEqualToInput(optionalName): SQL`
```ts
selectProfile = dd.select(user.id, user.name, user.sig).whereSQL(user.name.isEqualToInput());
```
Is equivalent to:
```ts
selectProfile = dd.select(user.id, user.name, user.sig).where`${user.name} = ${mm.input(
user.name,
)}`;
```
##### `Column.isNotEqualTo` and `Column.isNotEqualToInput`
Similar to `isEqualTo` and `isEqualToInput`, uses `<>`(not equal to operator) instead.
##### `.byID()` and `by()`
```ts
selectByID = mm.selectRow(user.id, user.name, user.sig).byID();
```
Is equivalent to 3 expressions listed below:
```ts
// 1
mm.selectRow(user.id, user.name, user.sig).where`${user.id} = ${user.id.toInput()}`;
// 2
mm.selectRow(user.id, user.name, user.sig).whereSQL(user.id.isEqualToSQL(user.id.toInput()));
// 3
mm.selectRow(user.id, user.name, user.sig).whereSQL(user.id.isEqualToInput());
```
`byID` automatically sets table's primary key as input as `WHERE`, to specify another column, use `by` instead:
```ts
selectByName = mm.selectRow(user.id, user.name, user.sig).by(user.name);
```
#### Predefined System Calls
As raw SQL expressions enable you to write any SQL, you may do this for a `DATETIME` column to set it to current time when inserted:
```ts
updateLastLogin = dd
.updateOne()
.set(user.lastLogin, mm.sql`NOW()`)
.byID();
```
As these system calls are commonly used, mingru-models supports them as predefined system calls listed below:
```ts
enum SQLCallType {
localDatetimeNow, // NOW() for DATETIME
localDateNow, // NOW() for DATE
localTimeNow, // NOW() for TIME
count, // COUNT()
avg, // AVG()
sum, // SUM()
coalesce, // COALESCE()
min, // MIN()
max, // MAX()
year,
month,
week,
day,
hour,
minute,
second,
utcDatetimeNow,
utcDateNow,
utcTimeNow,
timestampNow,
}
```
All predefined system calls are under the root `dd` namespace:
```ts
// These three are equivalent
updateLastLogin = dd
.updateOne()
.set(user.lastLogin, mm.sql`NOW()`)
.byID();
updateLastLogin = dd
.updateOne()
.set(user.lastLogin, mm.sql`${mm.localDatetimeNow()}`)
.byID();
updateLastLogin = dd.updateOne().set(user.lastLogin, mm.localDatetimeNow()).byID();
```
### More on `SELECT` Actions
#### `orderByAsc` and `orderByDesc`
```ts
selectUser = dd.select(user.name, user.age).byID().orderByAsc(user.name).orderByDesc(user.age);
```
#### Alias via `as`
Can use `Column.as` to add the SQL `AS` alias to a selected column:
```ts
selectUser = mm.selectRow(user.name, user.post_count.as('count'));
```
Generates the following SQL:
```sql
SELECT `name`, `post_count` AS `count` from user;
```
#### Pagination
##### `paginate`
Pagination can be achieved by calling `paginate` methods following `selectRows`:
```ts
selectUsersWithPagination = mm.selectRows(user.id, user.name);
```
Implementations should expose arguments to set the underlying SQL `LIMIT` and `OFFSET` values, here is the Go method signature generated by [mingru](https://github.com/mgenware/mingru) from the action above:
```go
func (da *TableTypeUser) SelectUsersWithPagination(queryable dbx.Queryable, limit int, offset int, max int) ([]*SelectUsersWithPaginationResult, int, error)
```
##### `selectPage`
Pagination can also be done via `selectPage` method, the `selectPage` usually generates a method built upon the SQL `LIMIT` and `OFFSET` clauses but exposes higher level arguments thus provides more convenience:
```ts
selectPagedUsers = mm.selectPage(user.id, user.name);
```
[mingru](https://github.com/mgenware/mingru) converts the action above to the following Go func:
```go
func (da *TableTypeUser) SelectPagedUsers(queryable dbx.Queryable, page int, pageSize int) ([]*SelectPagedUsersResult, bool, error)
```
Notice the `limit` and `offset` arguments are gone, `page` and `pageSize` are exposed instead. Also the second return value changed from `rowsFetched`(`int`) to `hasNextPage`(`bool`).
### `UPDATE` Actions
mingru-models supports the following kinds of `UPDATE` actions:
```ts
// Updates a row and checks rows affected to make sure only one row is updated.
// Implementations should throw an error if used without a WHERE clause.
function updateOne(): UpdateAction;
// Updates rows base on some conditions
// Implementations should throw an error if used without a WHERE clause.
function updateSome(): UpdateAction;
// Updates all rows.
function unsafeUpdateAll(): UpdateAction;
```
To set individual column values, use `UpdateAction.set(column, sql)`, e.g. set an `user.sig` to a random string:
```ts
updateUserSig = dd
.updateOne()
.set(user.sig, mm.sql`'My signature'`)
.byID();
```
Or, use user input as column value:
```ts
updateUserSig = dd.updateOne().set(user.sig, user.sig.toInput()).byID();
```
To set multiple columns, just call `set` one by one:
```ts
updateUserSig = dd
.updateOne()
.set(user.sig, user.sig.toInput())
.set(user.name, mm.sql`'Random name'`)
.byID();
```
#### `setInputs`
Most of the time, you will be using `UPDATE` action with user inputs, so you probably always end up with this:
```ts
updateManyColumns = dd
.updateOne()
.set(user.sig, user.sig.toInput())
.set(user.name, user.name.toInput())
.set(user.age, user.age.toInput())
.set(user.gender, user.gender.toInput())
.byID();
```
To simplify this, `UpdateAction` also has a method called `setInputs`, you can pass an array of columns, all of them will be considered inputs. The above code could be rewritten as using `setInputs`:
```ts
updateManyColumns = dd.updateOne().setInputs(user.sig, user.name, user.age, user.gender).byID();
```
You can also mix this with the `set` method mentioned above:
```ts
updateManyColumns = dd
.updateOne()
.set(user.type, mm.sql`1`)
.setInputs(user.sig, user.name, user.gender)
.set(user.age, mm.sql`18`)
.byID();
```
`setInputs` can also be called with no arguments, in this case, all remaining columns (haven't been set yet) will be automatically set as inputs:
```ts
updateManyColumns = dd.updateOne().setInputs(user.sig).setInputs(); // All columns other than `user.sig` will be set as inputs
```
### `INSERT` actions
```ts
// Inserts a new row, and returns inserted ID.
function insertOne(): InsertAction;
// Inserts a new row.
function insert(): InsertAction;
// The unsafe version of `insertOne` and `insert` does not check if all columns are set.
// See the "Unsafe Insert` section below for details.
function unsafeInsertOne(): InsertAction;
function unsafeInsert(): InsertAction;
```
Example:
```ts
// Insert a new user
insertUser = dd
.insertOne()
.set(user.sig, mm.sql`''`)
.set(user.name, user.name.toInput())
.set(user.age, user.age.toInput());
```
#### `setInputs` and `setDefaults`
`INSERT` action can also use `setInputs` like in `UPDATE` action:
```ts
insertUser = dd
.insertOne()
.set(user.sig, mm.sql`''`) // Set sig column to an empty string
.setInputs(user.name, user.age); // Set user.name and user.age as inputs
insertUser = dd
.insertOne()
.set(user.sig, mm.sql`''`) // Set sig column to an empty string
.setInputs(); // Calling setInput with no args simply sets all other columns of this table as inputs
```
`setDefaults` is like `setInputs` except it set the target column to its default value instead of an input:
```ts
insertUser = dd
.insertOne()
.setInputs(user.name, user.age) // Set user.name and user.age as inputs
.setDefaults(); // Set other columns to their default values
```
#### Unsafe Insert
By default, insert action throws an error when not all columns are set (not including `AUTO_INCREMENT` columns), even if columns have default values, you should always explicitly set them via `setDefaults`:
```ts
// Set all columns to their default values.
insertUser = mm.insertOne().setDefaults();
// Set all columns as inputs.
insertUser = mm.insertOne().setInputs();
```
To bypass this check, use the unsafe version instead, i.e. `unsafeInsertOne` and `unsafeInsert`.
### `DELETE` actions
```ts
// Deletes a row and checks rows affected to make sure only one row is updated.
// Implementations should throw an error if used without a WHERE clause.
function deleteOne(): DeleteAction;
// Deletes rows based on some conditions
// Implementations should throw an error if used without a WHERE clause.
function deleteSome(): DeleteAction;
// Delete all rows.
function unsafeDeleteAll(): DeleteAction;
```
Example:
```ts
// Delete an user by ID.
deleteByID = mm.deleteOne().byID();
// Delete all users by a specified name.
deleteByName = mm.deleteSome().whereSQL(user.name.isEqualToInput());
// Delete all users.
deleteAll = mm.unsafeDeleteAll();
```
## Advanced Topics
### Default Values
When set a default value to a column, two things happen:
- Default values are included in `CREATE TABLE` SQL.
- Default values are also explicitly set in `INSERT` and `UPDATE` actions.
Setting default values in `CREATE TABLE` also makes it hard to attach a dynamic value to a column, e.g. setting `NOW()` in a `DATETIME` column. In this case, you can use the `noDefaultOnCSQL` property to disable setting default value on generated `CREATE TABLE` SQL:
```ts
a = mm.int().default(1);
b = mm.int().default(1).noDefaultOnCSQL;
```
The generated `CREATE TABLE` SQL:
```sql
CREATE TABLE `user` (
`a` INT NOT NULL DEFAULT 1,
`b` INT NOT NULL
)
CHARACTER SET=utf8mb4
COLLATE=utf8mb4_unicode_ci
;
```
Note that the columns above have same generated `INSERT` and `UPDATE` code since default values are also explicitly set in `INSERT` and `UPDATE` actions.
### `JoinTable`
Imagine the following join:
```ts
post.user_id.join(user).name;
```
It returns an object of `JoinedColumn`:
```ts
export declare class JoinTable {
srcColumn: Column;
destTable: Table;
destColumn: Column;
path: string;
tableInputName(): string;
}
```
An anatomy of a `JoinTable`:
```
post.user_id.join(user).name;
-----------------------------
| | | |
srcColumn | destTable |
| | | |
|--- JoinTable |
| |
-------- destColumn
```
Multiple joins are also allowed:
```
cmt.post_id.join(post).user_id.join(user).name
| |
--------------------
JoinTable
| |
--------------------
JoinTable
```
- For first joined table:
- `srcColumn`: `cmt.post_id`
- `destTable`: `post`
- `destColumn`: `post.user_id`
- For second joined column:
- `srcColumn`: `<first joined table>.user_id`
- `destTable`: `user`
- `destColumn`: `user.name`
### Reuse a Joined Table
Suppose we need to select post author's name and URL from a comment of the post. We can do:
```ts
const cols = [
comment.post_id.join(post).user_id.join(user).name,
comment.post_id.join(post).user_id.join(user).url,
];
```
The code above can be simplified as below:
```ts
const joinedUser = comment.post_id.join(post).user_id.join(user);
const cols = [joinedUser.name, joinedUser.url];
```
### Return values in a transaction
First, mingru-models use a key-based return value reference approach. So instead of saying "declare the second return value of a func as `insertedUserID`", we say "declare the return value named `id` of a func named as `insertedUserID`". This way our code looks more readable but it also requires us to give each return value a name. To name return values of a transaction, use `TransactAction.setReturnValues`.
Let's take a look at the transaction func below:
```go
// ===== Pseudo code for demonstration only =====
// TX body
var insertedUserID;
{
// TX inner body
_, insertedUserID, err = txMemberFunc1(/** ... */)
userName, err = txMemberFunc2(/** ... */)
_, err = txMemberFunc3(userName, /** ... */)
}
return insertedUserID
```
There are actually 3 types of variables above, those used as transaction func return values, those used by other transaction member functions, and those are both. To use a return value from other function, you have to declare it first by calling `Action.declareReturnValue`:
```ts
class MyTableTA extends mm.ActionGroup {
exampleTransaction = mm
.transact(
txMemberFunc1,
txMemberFunc2.declareReturnValues({
exportedNameOfInsertedID: 'insertedID',
}),
txMemberFunc3.declareReturnValue(exportedNameOfUserName, 'userName'),
)
.setReturnValues('insertedID', 'userName');
}
```