refineorm

1.0.0 • Public • Published

Refine ORM

WIP, some functionality could be not working as expected!

Refine is a type-safe ORM for TS/JS in both code-first and database-first paradigm that uses lambda/arrow function as the query definition that inspired by LINQ query style in C#. Refine are highly inspired by those popular .NET ORM such as Entity Framework and Linq2db, and also TS ORM such as TypeORM.

In other word, you may query in database as how you work with array. It allow you to write your query in a JS/TS' lambda to enable fast database prototyping on Node.js, React Native and Ionic, and support most popular database such as MySQL, MSSQL, PostgreSQL, Oracle and SQLite.

Refine may not as complicated and complex as many other ORM in JS/TS world but it will give you a very strong resemblance than any other ORM in way to query your data.

Working feature listed as:

  • Query API to let you work in lambda/arrow function
  • Model and data configuration with decorator
  • Associations in bi-directional, while uni-directional are partially supported for One-to-one
  • Inheritance pattern
  • Transaction
  • Multiple migration strategy
  • Lazy and eager loading for associations
  • Promise-based
  • EF-like DataContext

Expected features:

  • Fluent model configurator for Javascript
  • Custom repository
  • Navigation property for associations

Installation

npm install --save refineorm

# one of
npm install --save mysql named-placeholder
npm install --save pg
npm install --save mssql
npm install --save oracledb
npm install --save sqlite

If you use Typescript make sure to include following configuration in your tsconfig.json

"emitDecoratorMetadata": true,
"experimentalDecorators": true,

Also since Refine rely very much on ES6 arrow function, you must set your target to minimum of es6, however esnext are more reccomended. You will know why after you read the detail below.

Get Started

Configuring your model

Refine model are defined through a class, so you may start with creating your class as like the following

export class User {
    name: string;
    job: string;
    age: number;
    maritalStatus: boolean;
    address: string;
}

Now if you done with your model, you may start decorating your class with metadata that needed to sturcture out your physical database

@Table('users')
export class User {

    @Key()
    name: string;

    @Column()
    job: string;

    @Column()
    age: number;

    @Column('isMarried')
    maritalStatus: boolean;

    @Column()
    address: string;
}

From the latter, we decorate our class with a @Table and @Column for the properties. Both decorator may accept string paramater to rename your table or property into more suited name of your choice.

Also we use @Key to decorate name as our primary key. Notice how we only use @Key without a @Column. Since @Key indicate that it is a primary key and since primary key must be a column, you may omit the @Column from it.

However the @Key decorator don't accept any parameter as how @Column accept string as the physical column name. So if you want to name your column, you have to pass it with the @Column as the following example

@Key() @Column('userName')
name: string;

Fluent model configuration

If you use a plain Javascript and can't work with decorator, or you may but you hate it because it's not a standard even in ESNext, you may use fluent model configurator to describe your table structure. You can follow the example below

// TO DO

Creating the DataContext

All interaction with database in Refine are passed through DataContext. In simple word, it is a representation of your connection to the database and even the strcuture of the database itself. First of all, you need to create a class that extends the base DataContext class. Let's define it as the following

export class MyContext extends DataContext {

    static readonly config: ConnectionConfig = {
        database: 'skrpsi',
        host: 'localhost',
        password: '',
        port: 3306,
        type: 'mysql',
        user: 'root'
    };

    constructor() {
        super(MyContext.config, new MysqlProvider());
    }

}

You can suit the config as your own connection configuration. In the example above, we use MySQL as the database provider and we define it from the type property inside the config. The valid type are mysql, pgsql, mssql, oracle, and sqlite. If you want to change your vendor or provider, you can simply change it.

Adding your DataSet

If DataContext is a represetation of your connection and database, DataSet is a representation of the table inside your database. Since the tables are bound to database, so does the DataSet are bound to DataContext. To add your dataset to your context, you may see the following

export class MyContext extends DataContext {

    static readonly config: ConnectionConfig = {
        database: 'skrpsi',
        host: 'localhost',
        password: '',
        port: 3306,
        type: 'mysql',
        user: 'root'
    };

    readonly users: DataSet<User> = this.fromSet(User);

    constructor() {
        super(MyContext.config);
    }

}

We add the dataset of type User as a property and initialize it with this.fromSet(..). We have to pass the class, in this case User, as the parameter because TS don't let us to read the generic type. Also notice that readonly modifier are added to ensure no side-effect are happening to the dataset.

Setting up your Migration Strategy

In Refine, migration strategy are the way to handle migration when changes detected in your model. Migration strategy are defined with MigrationStrategy enum, and the most basic is Never, OnEmpty and OnChange. Never are the only valid strategy if you want to work in database-first, while the other can be used to handle the code-first paradigm.

By default, DataContext will implement OnEmpty as it strategy, meaning that the database generation only happen when the database are completely empty. If you want to change it, you may override the migrationStrategy in your data context as below

get migrationStrategy(): MigrationStrategy {
    return MigrationStrategy.RecreateOnChange;
}

Setting your strategy to OnChange will ensure that the database are generated everytime a change happen in the model.

Working with your first query

Inserting your object

After you have done with your model, data context and the data set, now you only need to write somewhere in your code your first query. Let's start with inserting your first User.

async function someWhereInCode() {

    const user = new User();
    user.name = 'John Doe';
    user.job = 'JS Developer';
    user.age = 33;
    user.maritalStatus = false;
    user.address = 'Somewhere in NPM';

    const context = new MyContext();
    await context.users.insert(user);

    await context.release();

}

Notice that we use async modifier in the function and await for the insert and release. If your environment yet to support it you can use the common then as how you work in promise. And don't forget to call release in the end if you want to close and release the resource immediately.

Reading your data

Now since the table have a record, we can begin to write our query to read the data. To read the user by it's name we can write it as

async function someWhereInCode() {
    // -- the previous code

    const john = await context.users.first(x => x.name == 'John Doe');
    console.log(john.job) // output: JS Developer

    await context.release();
}

The first method are equivalent with find in array, it return the first element. Query from above will return the first element with name = 'John Doe'.

If you want to read only one data and such data must be entirely unique, you could use single(...) instead

async function someWhereInCode() {
    // -- the previous code

    try {
        const john = await context.users.single(x => x.name == 'John Doe');
        console.log(john.job) // output: JS Developer
    }
    catch(err) {
        console.log("User with name 'John Doe' are more than 1");
    }

    await context.release();
}

The single method will throw an error when data that fulfill the predicate are more than one record. However, if you sure that it may only exists one record and you didn't need it to be checked, you should use first as it will only send the query once, while single will send 2 query in which first checking whether such data only have 1 record and the second is to read the actual data.

Selecting and filtering your data

If you already have 10 users in the database, and you want to filter it by age that is greater or equals 30, you can use the below query

const filtered = context.users.where(x => x.age > 30);
for await (const user of filtered) {
    // do something with the filtered data here
}

That's how you're querying with the where clause. But why didn't where awaited as how we did it with first and how did it get executed? The query get executed when an access or iteration happens to the filtered or an element that it held. In the above, it's happen in the for statement.

Simply, unlike first which return an object, where method will return an AsyncCollection which is also a base interface of DataSet that will let you interact with the the table. In other word, this mechanism allow you to chain or build your query dynamically as how common QueryBuilder pattern in other ORM. But instead of a string, it typed in a valid expression. Another example can be seen below

let filtered = context.users
                      .where(x => x.age > 30)
                      .select(x => x.name);

for await (const name of filtered) {
    // name will be string
    // do something with name
}

The example will return names of users with age >= 30. The select method is equivalent with map in array. If you need to get more than one column, you could use the object literal instead

let filtered = context.users
                      .where(x => x.age > 30)
                      .select(x => ({
                          name: x.name,
                          job: x.job
                      }));

for await (const name of filtered) {
    // name will be string
    // do something with name
}

If your current environment has yet to support the asyncIterable or for await statement, as it was only supported in ES2018, you may cast your data set immediately with toArray() or toCollection().

const filtered = ... // -- defined as previously
const arr = await filtered.toArray();

for(const data of arr) {
    // do something with data
}

Calling both method will immediately execute the stacked query without the need to accessing or iterating over it's elements. The only difference between both are toCollection() will return a Collection that was very similar to AsyncCollection but without the async.

CAUTION! If you plan to iterate your query or collection more than one, you should always save the returned data from toArray() or toCollection() before you begin your first iteration, and use the value returned from the method as the iterable source (the most right side in for .. of/in ..) in all the iteration. Each iteration to raw AsyncCollection will execute the query each time and none of the returned result will be saved unless you do it manually.

const filtered = context.users.where(x => x.age > 30);

// === this is bad - DON'T ===
for await (const user of filtered) {
    // ...

    if(user.age < 25)
        break;
}

// ....

for await (const user of filtered) {
    // filtered query will get executed again before reaching this line for the first time
    // do something with user for the second time
}
// =======================

// === this is good - DO ===
const data = await filtered.toArray();
for await (const user of data) {
    // ...

    if(user.age < 25)
        break;
}

// ....

for await (const user of data) {
    // keep iterating with records in data
    // do something with user for the second time
}
// ========================

Reading all data from table

If you want to read all the data in the DataSet, you only need to iterate over it like how we did it in the previous, or how the below code written

for await (const user of context.users) {
    // do something with user here
}

Paginating your query

Refine make it easy to paginate or limiting you query. This achieved through the take and skip methods

const limitPerPage = 8;
let currentPage = 1;

const context = new MyContext();
const query = context.users
                     .where(x => x.age >= 30)
                     .orderBy(x => x.age)
                     .skip((currentPage - 1) * limitPerPage)
                     .take(limitPerPage);

for await (const user of query) {
    // do something
}

await context.release();

The example will limiting your result to just 8 records. Also notice that we use orderBy with x.age as it selector. If you want to order or sort it by descending, you can use orderByDesc instead.

You can read more about composing your query and all the possible option here

Manipulating your data

Updating and deleting one record

To update or deleting one data in the database, you can simply use update or delete with your object as it paramater as how you do it with insert operation

const context = new MyContext();

const mark = await context.users.first(x => x.name == "Mark");
mark.job = '.NET Developer';
mark.age = 30;

await context.users.update(mark);

const alex = await context.users.first(x => x.name == "Alex");
await context.delete(alex);

await context.release();

Updating many

If you need you need to updating records based on a condition, AsyncCollection have a set method that can be chained with finishing update method to update all data by the composed query

const context = new MyContext();
const updated = await context.users
                             .where(x => x.age < 25)
                             .set(x => x.maritalStatus, false);
                             .execute();

await context.release();

You need to specify what column to be updated with set method. First parameter are the selector of column that want to be changed, while the second parameter is the value. Alternatively, set also have an overload that let you to write an assigment expression instead.

const context = new MyContext();
const updated = await context.users
                             .where(x => x.age < 25)
                             .set(x => x.maritalStatus = false);
                             .execute();

await context.release();

If the given expression is not an assigment, set will throw an error.

Deleting many

Similar to the update, AsyncCollection also have delete method to delete all records that matched the composed query

const context = new MyContext();
const updated = await context.users
                             .where(x => x.age < 20)
                             .delete();

await context.release();

Forcing database synchronization

You may asks where did we create the table since we didn't mention it anywhere on above. Well the migration are taken in place before your first query are sent to the database. So keep in mind your first query could be cumbersome somehow. And if you somehow didn't want to wait until your first query, you may call the forceSynchronize()

const context = new MyContext();

await context.forceSynchronize();

// --  our first query in below

Keep in mind that forceSynchronize() can only ran one time after your application started, and will throw an Error if a call happened again.

Creating association

In Refine, association are built with @HasOne(...) and @HasMany(...) decorator. The most easy way to define your association is by using bi-directional way, in which both model must decorate it's related property with either @HasOne or @HasMany, although some cases will enable you to write your association in a uni-directional way.

One-to-one relation

We will modify the defined User model from the above and give it a one-to-one relation with, let say, a UserBilling in which a user may only have it once. Let create the UserBilling class first and decorate it as how we do it with User previously.

@Table('user_billing')
export class UserBilling {

    @Key()
    @HasOne(of => User)
    user: User;

    @Column()
    isActive: boolean;

    @Column()
    isSuspended: boolean;

    @Credentials()
    securityKey: string;

}

The @HasOne decorator accept a first argument to be a selector of the class that being referenced. It also has an optional second argument that accept a selector to referenced property of the first argument. This optional arguments will be explained in detail further on below.

Notice that in the UserBilling, we decorate the property user which have type of User as the primary key. This format alone would be enough to make a uni-directional one-to-one relationship, in which a user column, which is a primary key, will have a reference or foreign key to column name in User.

Also note the @Credentials decorator in property securityKey. This decorator will hide the securityKey column while reading the database unless it requested manually by select or loadWith. This decorator is good when you want to restrict a sensitive data, like password or token, being loaded unexpectedly.

Now we will also give a property that have Billing as it type and decorate it with @HasOne too to make a bidirectional relation.

@Table('users')
export class User {
    // -- another property up here

    @HasOne(of => UserBilling)
    billing: UserBilling;
}

Readme

Keywords

Package Sidebar

Install

npm i refineorm

Weekly Downloads

1

Version

1.0.0

License

MIT

Unpacked Size

305 kB

Total Files

171

Last publish

Collaborators

  • drizward