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 converts mingru-models to Go code
NOTE: The documentation below is outdated!!! We are working on a brand new documentation site. Stay tuned.
yarn install mingru-models
To create a table:
- Create a class inheriting from
mm.Table
. - Add table columns as instance properties.
- Export a table object via
mm.table
.
For example, a table named User
with 2 columns, id
and name
:
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.
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.
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 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:
// `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.
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 below):
// 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:
// 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:
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
By default, property name reflects the column name, if you need a different name from property name, use Column.setDBName
method:
// 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');
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.
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 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
):
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:
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);
Similar to defining a table, to define table actions, we need declare a class inheriting from mm.ActionGroup
(TA stands for table actions), and define actions as properties, finally export a single table actions object via mm.ta
.
// 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);
mingru-models supports the following kinds of SELECT
actions:
// 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 columnsselectRows
returns an array of row objects each containing all selected columnsselectField
returns the single selected field
For example, in mingru, consider the following models and actions:
// ----------- 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):
// 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)
To select all columns of a table, simply call select
or selectRows
with no arguments.
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:
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${user.id} = 1`;
mingru translates this into:
SELECT `id`, `name`, `sig` FROM `user` WHERE `id` = 1
More complex queries:
selectUserProfile = dd.select(user.id, user.name, user.sig)
.where`${user.id} = 1 AND ${user.sig} <> 'haha'`;
mingru translates this into:
SELECT `id`, `name`, `sig` FROM `user` WHERE `id` = 1 AND `sig` <> 'haha'
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:
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${user.id} = ${mm.input(
user.id,
)}`;
mingru translates this to the following Go code:
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
:
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:
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
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.
Shortcut to mm.input(column, optionalName)
:
selectUserProfile = dd.select(user.id, user.name, user.sig).where`${
user.id
} = ${user.id.toInput()}`;
selectUserProfile = dd.select(user.id, user.name, user.sig).whereSQL(user.name.isEqualTo`"Admin"`);
Is equivalent to:
selectProfile = dd.select(user.id, user.name, user.sig).where`${user.name} = "Admin"`;
selectProfile = dd.select(user.id, user.name, user.sig).whereSQL(user.name.isEqualToInput());
Is equivalent to:
selectProfile = dd.select(user.id, user.name, user.sig).where`${user.name} = ${mm.input(
user.name,
)}`;
Similar to isEqualTo
and isEqualToInput
, uses <>
(not equal to operator) instead.
selectByID = mm.selectRow(user.id, user.name, user.sig).byID();
Is equivalent to 3 expressions listed below:
// 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:
selectByName = mm.selectRow(user.id, user.name, user.sig).by(user.name);
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:
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:
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:
// 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();
selectUser = dd.select(user.name, user.age).byID().orderByAsc(user.name).orderByDesc(user.age);
Can use Column.as
to add the SQL AS
alias to a selected column:
selectUser = mm.selectRow(user.name, user.post_count.as('count'));
Generates the following SQL:
SELECT `name`, `post_count` AS `count` from user;
Pagination can be achieved by calling paginate
methods following selectRows
:
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 from the action above:
func (da *TableTypeUser) SelectUsersWithPagination(queryable dbx.Queryable, limit int, offset int, max int) ([]*SelectUsersWithPaginationResult, int, error)
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:
selectPagedUsers = mm.selectPage(user.id, user.name);
mingru converts the action above to the following Go func:
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
).
mingru-models supports the following kinds of UPDATE
actions:
// 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:
updateUserSig = dd
.updateOne()
.set(user.sig, mm.sql`'My signature'`)
.byID();
Or, use user input as column value:
updateUserSig = dd.updateOne().set(user.sig, user.sig.toInput()).byID();
To set multiple columns, just call set
one by one:
updateUserSig = dd
.updateOne()
.set(user.sig, user.sig.toInput())
.set(user.name, mm.sql`'Random name'`)
.byID();
Most of the time, you will be using UPDATE
action with user inputs, so you probably always end up with this:
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
:
updateManyColumns = dd.updateOne().setInputs(user.sig, user.name, user.age, user.gender).byID();
You can also mix this with the set
method mentioned above:
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:
updateManyColumns = dd.updateOne().setInputs(user.sig).setInputs(); // All columns other than `user.sig` will be set as inputs
// 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:
// Insert a new user
insertUser = dd
.insertOne()
.set(user.sig, mm.sql`''`)
.set(user.name, user.name.toInput())
.set(user.age, user.age.toInput());
INSERT
action can also use setInputs
like in UPDATE
action:
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:
insertUser = dd
.insertOne()
.setInputs(user.name, user.age) // Set user.name and user.age as inputs
.setDefaults(); // Set other columns to their default values
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
:
// 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
.
// 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:
// 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();
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
andUPDATE
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:
a = mm.int().default(1);
b = mm.int().default(1).noDefaultOnCSQL;
The generated CREATE TABLE
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.
Imagine the following join:
post.user_id.join(user).name;
It returns an object of JoinedColumn
:
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
Suppose we need to select post author's name and URL from a comment of the post. We can do:
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:
const joinedUser = comment.post_id.join(post).user_id.join(user);
const cols = [joinedUser.name, joinedUser.url];
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:
// ===== 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
:
class MyTableTA extends mm.ActionGroup {
exampleTransaction = mm
.transact(
txMemberFunc1,
txMemberFunc2.declareReturnValues({
exportedNameOfInsertedID: 'insertedID',
}),
txMemberFunc3.declareReturnValue(exportedNameOfUserName, 'userName'),
)
.setReturnValues('insertedID', 'userName');
}