Miss any of our Open RFC calls?Watch the recordings here! »

mfsqlchecker

0.0.23 • Public • Published

mfsqlchecker

Statically validate correctness of all your SQL queries. TypeScript, PostgreSQL

Example Animation

Similar projects

What is mfsqlchecker

mfsqlchecker is a thin layer on top of node-postgres. You continue to write queries using regular SQL to interact with your PostgreSQL database, but mfsqlchecker automatically verifies that all your queries are correct, and that the result columns are of the expected type. This verification step is performed at compile time, so that you can have a high level of confidence that there will be no errors during production.

Motivation and Benefits

For small projects that only have a few dozen queries, using node-postgres directly works fine.

But for large projects containing hundreds of SQL queries, things start to break down to due the lack of compile-time checking, and un-ergonomic composability

mfsqlchecker helps address these issues:

Compile-Time Static Checking of Queries

The first benefit that you get from the static checking provided by mfsqlchecker is rapid feedback during development. Your queries are checked while you are writing them in your editor. Syntax errors will be immediately detected, as well as typos in table names or column names, invalid type comparisons, and any other SQL error.

Additionally, mfsqlchecker will verify that the column types returned by your query match the TypeScript types in your code. mfsqlchecker will actually automatically infer the returned column types for each query, and can generate a corresponding TypeScript shim directly inline with the query.

For example: if your SQL query returns a result from a TEXT column in your database, but you try to assign the result to a TypeScript variable of type number, then you will get a compile-time error.

The TypeScript/PostgreSQL type-checking also works in the other direction: mfsqlchecker validates that the types of the TypeScript parameters are valid based on their usage in the SQL query.

This rapid-feedback speeds up development, but more importantly, the static-checking system allows much more fluid refactoring of your code and of your database schema.

Example: You have a person table with a first_name column and a last_name column. You want to combine them into a single name column. With mfsqlchecker, simply write a migration to perform the change. Then, the tool will notify you of all of the SQL queries in your project that now fail because they reference first_name or last_name. After you correct them and have no more errors, you can be confident that your refactoring is succesfully complete. This technique scales nicely to more complex refactorings (such as extracting a one-to-one foreign key to a new many-to-many junction table, etc...)

This ease-of-refactoring is familiar to proponents of statically-typed languages, and mfsqlchecker's static checker offers the same benefits.

Example Query

interface Employee {
    name: string;
    departmentName: string;
    phoneNumber: string | null;
    salary: number;
}
 
export async function getEmployeesWithMinSalary(conn: Connection, minSalary: number): Promise<Employee[]> {
    const rows = await conn.query<{
        name: Req<string>,             // /---------------------------\
        department_name: Req<string>,  // |  This code block is       |
        phonenumber: Opt<string>,      // |  generated automatically  |
        salary: Req<number>            // \---------------------------/
    }>(conn.sql
        `
        SELECT
            employee.name,
            department.name AS department_name,
            phonenumber,
            salary
        FROM employee
        JOIN department ON employee.department_id = department.id
        WHERE salary >= ${minSalary}
        `);
 
    return rows.map<Employee>(row => ({
        name: row.name.val(),
        departmentName: row.department_name.val(),
        phoneNumber: row.phonenumber.valOpt(),
        salary: row.salary.val()
    }));
}

When you run the mfsqlchecker tool, all of the following will be validated:

  • The SQL query itself is valid: Contains no syntax errors, all referenced tables and columns exist, there are no SQL type errors, etc...
  • The minSalary parameter is allowed to be compared with the "salary" column (In this case everything is good: minSalary is a TypeScript "number", the "salary" column is a PostgreSQL INT, and so the comparison is valid).
  • The return columns of the query match the names, type, and "nullability" listed. Note that mfsqlchecker can automatically generate this stub code. You should never manually write it. After editing your query, use the mfsqlchecker "Quick Fix" to automatically update the return columns type. (See the above animation for an example)
  • Each returned column can either be Req (Required) or Opt (Optional)
  • Req means that the column always returns a non-null value. This is determined automatically by examining the originating table where the column comes from, and checking if it is declared NOT NULL.
  • Opt means that the column may contain a null value. This can happen when the originating table column may be null, or if the column is a result of some SQL expression. If you are sure that the column does not contain null (for example, if it is the concatenation of two non-null text columns), then you can access it using the forceNotNull() method (instead of valOpt())

Composability

Composability means that when we have 2 queries that are similar, we don't want ...

Security

Even with an experienced team and strict dicipline, it is still

Since all queries are guaranteed to ..., therefore SQL injection vulnerabilities are impossible

Performance

Because each query is known to be statically formed, we are guaranteed to get maximum effectiveness of the database's internal query cache, minimizing query parse time and query planning time.

Basics

If you don't want to use the built-in mfsqlchecker migration engine, then you can dump your PostgreSQL database schema to an .sql file and point mfsqlchecker to it

Queries

To make a query call one of the following 3 methods on your connection object:

  • query: For queries that can contain any number of rows(zero, one, or more). This is usually what you want. This will return an array of "Row" objects.

  • queryOne: For queries that will always return exactly one row. This should only be used when the structure of your query guarantees that it will always return a single row. For example SELECT COUNT(*) FROM [..], or a top-level SELECT EXISTS(..). This will return a single "Row" object.

  • queryOneOrNone: For queries that always return either a single row, or no rows. This should be used for queries that select a single row using based on a unique id. If a matching row is found then returns a "Row" object, otherwise returns null.

Easy, Automatic SQL Views

... defineSqlView ...

Reusable SQL fragments

... sqlFrag ...

Dedicated INSERT functionality

When using mfsqlchecker, you use regular SQL syntax for all queries. But there is an optional dedicated syntax for "INSERT". You can still write INSERT queries as regular queries, but it is recommended to use the dedicated insert functionality because:

  1. It is easier to read/write when there are lots of columns
  2. Simple syntax for inserting multiple rows
  3. It catches errors where you forgot to specify a required column

Here is an example "raw" INSERT query:

export async function insertEmployee(conn: Connection, employee: Employee): Promise<void> {
    await conn.query(conn.sql
        `
        INSERT INTO employee
            (salary, phonenumber, name, manager_id)
        VALUES
            (${employee.salary}, ${employee.phoneNumber}, ${employee.name}, NULL)
        `);
}

It is difficult and error-prone to verify that the column names match up with the values. It is very easy to make a mistake by, for example, mixing the order of the phonenumber and name columns. This becomes more likely when there are lots of columns.

Here it is rewritten using mfsqlchecker insert method:

export async function insertEmployee(conn: Connection, employee: Employee): Promise<void> {
    await conn.insert("employee", {
        salary: employee.salary,
        phonenumber: employee.phoneNumber,
        name: employee.name,
        manager_id: null
    });
}

Now we can more easily see each value together with its column. mfsqlchecker will still validate that the type of each value matches that of its table column, and additionally it will check that all required columns are listed.

If we need to add additional SQL to our INSERT statement, such as a RETURNING clause, or an ON CONFLICT clause, we can add it as the 3rd argument:

export async function insertEmployee(conn: Connection, employee: Employee): Promise<number> {
    const row = await conn.insert<{
        id: Req<EmployeeId>
    }>("employee", {
        salary: employee.salary,
        phonenumber: employee.phoneNumber,
        name: employee.name,
        manager_id: null
    }, conn.sql
        `
        RETURNING id
        `);
 
    return row.id.val();
}

NOTE: If your query contains an ON CONFLICT DO NOTHING clause, or an ON CONFLICT DO UPDATE ... WHERE clause, then you should use the insertMaybe() method instead of insert(), because it is possible for such a query to return 0 rows.

To insert multiple rows, use the insertMany() method (which also will perform all of the static checks discussed earlier):

export async function insertEmployees(conn: Connection, employees: Employee[]): Promise<void> {
    const vals = [];
    for (const employee of employees) {
        vals.push({
            salary: employee.salary,
            phonenumber: employee.phoneNumber,
            name: employee.name,
            manager_id: null
        });
    }
 
    await conn.insertMany("employee", vals);
}

insertMany also supports an optional 3rd argument, in case you need to add a RETURNING clause or ON CONFLICT clause.

NOTE: insert, insertMaybe, and insertMany do not support more complicated inserts involving subqueries, or computed SQL expressions. For those, just write a regular INSERT query (using the query method).

Enhanced checking of foreign keys on "id" style columns

... TODO ...

Gotchas

For the most part, if a query passes validation by mfsqlchecker's static checker, then the query is guaranteed to succeed also during runtime. But there are a few known cases where things break down.

Outer Joins and incorrectly detected Req Columns

mfsqlchecker is currently unable to detect the usage of outer joins. A query containing an outer

Type information lost during UPDATE

... add a cast ...

UNNEST

... here is a trick that works ...

How it works (Behind the Scenes)

mfsqlchecker analyzes all of TypeScript source code of your project, searching for all the SQL queries. It submits each one to a temporary PostgreSQL database, as a prepared statement. This allows it to check that the query is valid (correct syntax, valid table & column names, etc...), as well as query the type of each of the returned columns.

Install

npm i mfsqlchecker

DownloadsWeekly Downloads

98

Version

0.0.23

License

MIT

Unpacked Size

1.54 MB

Total Files

40

Last publish

Collaborators

  • avatar