ClickerMonkey / typed-query-builder

A fully featured fully typed query builder interface that supports multiple databases.
4 stars 0 forks source link

@typed-query-builder

The most advanced TypeScript query builder available! It can generate SQL, be translated to and from JSON, or run the query on local data.

But why? Do we need more? I think so. If you find yourself struggling to use an ORM or have problems using typical SQL features in a query builder this is the query builder for you. Nothing is more irritating than having to resort to SQL strings because what you're using doesn't support something simple. This library aims at providing a query building experience that feels like SQL, but with type safety and auto-completion. Not only that, but your queries can be ran against any supported database. This library also has a runtime implementation which is a local implementation of a database. This makes testing your business logic without connecting to a real database as easy as can be. Each database implementation has common SQL features but also features specific to that database if you really need to utilize uncommon functionality. If you also have custom expressions, functions, data types, etc you can easily merge your types and utilize them. With all of this power, this makes developing, refactoring, and testing easy.

Examples | FAQ | :warning: Walkthrough :warning:

Features

Type safe

A source is a table, a subquery, values (list of objects/tuples), or insert/update/delete expressions with a returning clause.

All sources have defined fields and types. As you join, select, and return fields the expressions and return type transforms to match the expressions. Type safety is good at warning when you're building an invalid query, informing what operations you can perform, and produces exactly the expected output for any highly dynamic built query.

Looks like SQL

Queries built should read like SQL. Too often do ORMs or query builders venture too far from SQL to the point where it's not clear what the output is going to look like or if it will work as you intended, and it ends up being too restrictive and you may end up reverting to using query strings. Not with typed-query-builder! (hopefully!)

Examples

SQL Features

Common Math Operations

Common Functions

Aggregate Functions

Window Aggregate Functions

Singular Interface

Even if the underlying database doesn't support particular functionality, it will appear to and the builder will substitue an equivalent expression when possible. Using a singular interface for communicating with the database also allows built queries to be used on any number of supported databases. This also makes it simple to support complex data types like geometry/geography.

For example, SQL Server doesn't have the following operations or functions, but they are supported seamlessly:

Customizable

Comes with common functions, operations, expressions, and data types. Its trivial to add your own and maintain type safety.

Custom Function Example

import { DialectPgsql } from '@typed-query-builder/pgsql';

interface UserFunctions {
  random(min: number, max: number): number;
}
// Adding to dialect
DialectPgsql.functions.setFormat('random', '(random() * ({1} - {0}) + {0})');
// the expression used in a query
func<'random', UserFunctions>(0, from(Table).count());

Custom Expression Example

import { ExprScalar, ExprKind } from '@typed-query-builder/builder';
import { DialectPgsql } from '@typed-query-builder/pgsql';

class MyExpr extends ExprScalar<number> {
  public static readonly id = ExprKind.USER_DEFINED_0;
  public getKind() { return ExprKind.USER_DEFINED_0 };
  public constructor(
    public min: Expr<number>, 
    public max: Expr<number>,
    public whole: boolean
  ) {}
}

DialectPgsql.transformer.setTransformer<MyExpr>(
  MyExpr,
  (expr, transform, out) => {
    const min = out.wrap(expr.min);
    const max = out.wrap(expr.max);
    const rnd = `random() * (${max} - ${min}) + ${min}`;

    return expr.whole ? `floor(${rnd} + 1)` : rnd;
  },
);

// the expression used in a query
new MyExpr(min, max, whole);

Powerful

Traditionally writing SQL can often be cumbersome. You may find yourself having to reuse the same expression over and over. For example, if you are selecting the distance between two locations - you may see it in the select, in a where condition, and in the order by. With typed-query-builder you can reuse previously defined expressions for ease of reading:

from(Persons)
  .select(({ person }, exprs, { geomDistance }) => [
    person.id,
    person.name,
    geomDistance(person.location, {x: 0, y: 0}).as('meters'),
  ])
  .where((sources, exprs, fns, { meters }) => [
    meters.lt(1000)
  ])
  .orderBy('meters')
; // returns { id, name, meters }[]

Runtime Implementation

@typed-query-builder/run is an implementation that allows you to perform any query on local data. A database implementation in TypeScript! This sort of functionality could be useful for any number of crazy scenarios. Imagine you have an application that you want to work offline. You can define all your business logic using query builders. A client and server could share the same logic however the client executes it on local data while sending the request off to the server to also process which runs the same logic against a real database. The client could verify the output from the server when it finally is able to communicate with it. If it doesn't match, and the client is carefully made, the local changes can be rolled back. If your application needs to work offline and you want to prevent concurrent modification of resources this may not work for you, but it is still possible to support advanced offline capabilities using this method.

import { runOn } from '@typed-query-builder/run';

const DB = {
  task: [
    { id: 1, name: 'Task 1', done: true },
    { id: 2, name: 'Task 2', done: false },
  ]
};

const results = from(Task)
  .select('*')
  .where(Task.fields.done)
  .run( runOn(DB) )
; // [{ id: 1, name: 'Task 1', done: true }]

SQL Implementations

SELECT

A source is a table, a subquery, values (list of objects/tuples), or insert/update/delete expressions with a returning clause.

(the order above is the recommended order, since sources need to be established before selects are defined, and the following functions after that can reference the sources and the defined selects.)

You can resolve a SELECT down to a list of objects or tuples, a first row, a singular value, an array of values, or a boolean on whether it returns results or not.

INSERT

A source is a table, a subquery, values (list of objects/tuples), or insert/update/delete expressions with a returning clause.

UPDATE

A source is a table, a subquery, values (list of objects/tuples), or insert/update/delete expressions with a returning clause.

DELETE

A source is a table, a subquery, values (list of objects/tuples), or insert/update/delete expressions with a returning clause.

Examples

Define Tables

import { table, tableFromType } from '@typed-query-builder/builder';

// First we define our tables
const Task = table({
  name: 'task',
  table: 'v_table', // optionally the real table name
  fields: { // these inform what the TS types will be
    id: 'INT',
    name: ['VARCHAR', 64],
    done: 'BOOLEAN',
    doneAt: 'TIMESTAMP',
    parentId: ['NULL', 'INT'], // nullable
  },
  fieldColumn: {
    doneAt: 'finished_at', // optionally the real column name
  },
});

// This method is also available, and won't result in TS errors if you have too many columns/fields.
interface TaskDTO {
  id: number;
  name: string;
  done: boolean;
  doneAt: Date;
  parentId?: number | null;
}

export const Task = tableFromType<TaskDTO>()({
  name: 'task',
  table: 'v_table',
  fields: ['id', 'name', 'done', 'doneAt', 'parentId'],
  fieldColumn: {
    doneAt: 'finished_at',
  },
});

Select

import { from, query, insert, update, deletes } from '@typed-query-builder/builder';

// SELECT * FROM task
from(Task).select('*');

// SELECT COUNT(*) FROM task;
from(Task).count();

// SELECT id, name FROM task WHERE done = true
from(Task).select(Task.only('id', 'name')).where(Task.fields.done);
from(Task)
  .select(({ task }) => [ 
    task.id, 
    task.name 
  ])
  .where(({ task }) => [
    task.done
  ])
;

// SELECT COUNT(*) FROM task WHERE done = true
Task.fields.done.count();

// SELECT MIN(doneAt) WHERE parentId = 34
Task.fields.doneAt.min().where(Task.fields.parentId.eq(34));

// SELECT name WHERE done = true
Task.fields.name.list(Task.fields.done); // string[]

// SELECT task.id, task.name, task.parentId, parent.name AS parentName FROM task LEFT JOIN task AS parent ON parent.id = task.parentId
from(Task)
  .joinLeft(Task.as('parent'), 
    ({ task, parent }) => task.parentId.eq(parent.id)
  )
  .select(({ task, parent }) => [
    task.id,
    task.name,
    task.parentId,
    parent.name.as('parentName'),
  ])
;

// SELECT all children of a given task, recursively - keeping track of their depth
query().with(
   // initial
  () => 
    from(Task)
      .select(({ task }, { constant }) => [
        constant(0).as('depth'),
        task.id,
        task.name,
        task.parentId
      ])
      .where(({ task }, { param }) => [
        task.id.eq(param('taskId')) // named parameter
      ])
      .as('task_tree'),
  // recursive (fetch children for each previous run)
  ({ task_tree }) =>
    from(Task)
      .select(({ task }) => [
        task_tree.depth.add(1),
        task.id,
        task.name,
        task.parentId
      ])
      .where(({ task }) => [
        task.parentId.eq(task_tree.id)
      ])
  )
  .from('source_tree')
  .select('*')
; // given { taskId } get { depth, id, name, parentId }[]

// SELECT 10 most recent tasks finished in the past 10 days
from(Task)
  .select('*')
  .where(({ task }, exprs, { currentDate, dateAddDays }) => [
    task.doneAt.isNotNull(),
    task.doneAt.between( dateAddDays(currentDate(), -10), currentDate() )
  ])
  .orderBy('doneAt', 'DESC')
  .limit(10)
;

Insert

// INSERT INTO task VALUES (id, name, done, doneAt, parentId) VALUES (DEFAULT, '...', DEFAULT, DEFAULT, DEFAULT)
insert(Task).values({ name: 'Complete Documentation' });

// INSERT INTO task (name) VALUES ('Task #1'), ('Task #2')
insert(Task, ['name']).values([['Task #1'], ['Task #2']]);

// TODO
// - insert example with extensive WITH & RETURNING expressions
// - insert with values from any source
// - insert with on duplicate key set

Update

// UPDATE task SET name = 'New Name' WHERE id = 10
update(Task).set('name', 'New Name').where(Task.fields.id.eq(10));
update(Task).set({ name: 'New Name' }).where(Task.fields.id.eq(10));

update(Task).set(
  ['name', 'done'], 
  ['New Name', true] // could be subquery which returns one [string, boolean]
).where(Task.fields.id.eq(10));

// TODO
// - update with multi-set with subquery
// - update with from
// - update example with extensive WITH & RETURNING expressions

Delete

// DELETE FROM task WHERE id = 10 RETURNING name
deletes(Task).where(Task.fields.id.eq(10)).returning('name');

// TODO examples:
// - delete example with extensive WITH & RETURNING expressions
// - delete with using

FAQ

1). When do you use Task.fields.name vs ({ task }) => task.name

You can pass expressions directly to many functions, but you can also use a "provider". A provider is a function which has the following parameters:

When you use table aliasing, you need to use the provider function.

2). I am getting a "Type instantiation is excessively deep and possibly infinite." error.

At the moment, occasionally TypeScript chokes on some of the types defined in this library. The workaround for the moment is to add // @ts-ignore before the problematic line to ignore the error. If you notice the error consistently occurs on a specific thing please file a bug report. A temporary work-around is to pull that functionality out into its own function so you only have to ignore it in one place.

TODO