npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

tableloader

v0.0.3

Published

DataLoader + SQL Tables, with adapters Knex.js & Kysely

Downloads

1

Readme

TableLoader

DataLoader + Table Associations = TableLoader (From the original creator of Knex.js)

A JavaScript (Node + ESM) library for relational database querying, focused on optimal type-safety, batching (avoiding N+1 queries), consistency, and correctness.

Ships with adapters for both Knex.js & Kysely, and instructions for building or contributing a new adapter if you're using something else to construct your queries.

Aims for queries that are lightweight, simple to modify via filters or scopes, easy to inspect, and with as good or better performance than the equivalent hand written sql queries, due to proper batching and optional column selection API.

It's especially well suited to avoid the N+1 query problem in GraphQL, but is useful in any situations where you'd use a query builder or ORM to handle your select statements.


Table of Contents

Overview / Guiding Principles

Database as the Source of Truth: The database has a well defined schema, and stable guarantees around uniqueness and identity. Introspecting and pulling this information into our runtime layer gives us better information about what we should expect when querying.

No Magic: It should be simple to trace the logic used to generate a query, and the query should represent what you would have written by hand. No obfuscation of what's happening behind the scenes, logging out the queries, or adding a debug breakpoint and stepping through the code should feel familiar.

Intentional APIs: The API design should be consistent, and large enough to support the most common use cases, but no larger. There should be a single, obvious approach to accomplish a given task, and it shouldn't try to handle every edge case - you can handle those using a query builder or raw SQL.

Making use of Batching: The batch loading pattern is incredibly helpful in reducing the number of queries to the database, but requires a lot of effort to do correctly in all situations. Tableloader aims to address these cases as much as possible.

Favor Readability over Micro-Optimizations: The difference between a for loop and a for of loop is meaningless when compared with the latency of a network request. This library is designed to optimize for reducing latter.

Keep Dependencies to a Minimum:

Don't try to do everything: Goes hand-in-hand with "intentional APIs". This library doesn't aim to replace a full-featured query builder layer, such as Knex or Kysely. Instead it should compliment it, providing well thought out abstractions for common data-fetching use cases.

How It Works

A TableLoader instance is created with knowledge of both type information and runtime metadata for the columns & keys in the target database:

// Type Info:
const adapter = new Adapter<DBTypes>(); // KnexAdapter or KyselyAdapter

const tableLoader = new TableLoader(adapter, {
  // Runtime Info:
  // Introspection typically generated via the Tableloader CLI (see section below)
  introspection: {
    primaryKeys: {
      user: "id",
      project: "id",
      membership: ["project_id", "user_id"],
    },
    columns: {
      user: ["id", "name", "created_at"],
      userInfo: ["user_id", "info"],
      project: ["id", "slug", "project_name", "created_at", "updated_at"],
      membership: ["project_id", "user_id", "role", "created_at"],
    },
    uniqueKeys: {
      project: ["slug"],
    },
  },
  // Written by hand, to represent the relations you wish to support in the API,
  // regardless of whether these are true ForeignKey relationships
  relations: {
    user: (t) => ({
      project: t.manyToMany("project", "membership"),
      userInfo: t.hasOne("userInfo").orThrow,
    }),
    project: (t) => ({
      user: t.manyToMany("user", "membership"),
    }),
  },
});

The concrete introspection metadata allows us to validate all of the defined relations, and keep a proper identity cache of all rows returned, via their primary and secondary (unique) keys.

To use TableLoader, we create a new context:

const ctx = tableLoader.makeContext();

The context acts as an identity cache of row results and internal DataLoaders. You'll generally create a new context per request/response, or similar unit of work, such as a queue job.

Each time a table or relation is queried, a DataLoader is either used or created & cached if it doesn't exist yet. Loaders are cached based on the constraints to the query:

const [user1, user2] = await Promise.all([
  // Both access the same dataloader, due to having the same filter
  ctx.table("user").filter({ status: "active" }).loadPk(1),
  ctx.table("user").filter({ status: "active" }).loadPk(2),
  //
]);

await Promise.all([
  // Again, both access the same dataloader, since they have the same relation
  ctx.relation("user:userInfo").loadFor(user1),
  ctx.relation("user:userInfo").loadFor(user2),
]);

SQL:

select `user`.* from `user` where `status` = 'active' and id in (1, 2)
select `user_info`.* from `user_info` where `user_id` in (1, 2)

Goals / Non-Goals

  • Goals:
    • Full type safety while dealing with relations
    • Observability: logs all queries executed within a given context, for simple inspection or snapshotting in tests
    • Works from a stable introspection of the database, catching obvious association errors at startup rather than runtime
    • Hooks to selectively load columns, if the selection set of columns are known ahead of time, such as when executing a GraphQL Document traversal
    • Handle composite keys as well as we can, don't make them an afterthought
  • For consideration:
    • Polymorphic associations. Not strictly against these, just didn't take the time to add them. Feel free to open an issue if you'd like to see them
  • Non-Goals:
    • Models / Model Files
    • Managing any sort of inserts / updates / deletes

Non-Goal explanation:

Models / Model Files

Tools like GraphQL already provide good separation between reads & writes. There's a lot of opinions around what a Model can / should do, and TableLoader aims to steer clear of that. We are concerned with mapping as close to the database as possible, and to focus on wins that address optimal query loading.

Feel free to build a more robust ORM on top of TableLoader and open a PR to link to it here!

Managing any sort of inserts / updates / deletes

Something for this might be added eventually, but it's not in the short term roadmap. Keeping an identity map of tables can be tricky with mutations, and it's often simpler to just do you your logic, and then issue a new tableLoader.makeContext()

Knex / Kysely provide good helpers for dealing with these sorts of things in the query builder directly, and we recommend you just use those.

Getting Started

In order to maintain type-safety both in TypeScript and at runtime, TableLoader requires a bit of initial configuration to get our types synced up with the database definition.

Generate Introspection Metadata

TableLoader requires a introspection, incuding the primary keys, unique keys, and columns for each table in a target database.

export interface IntrospectionShape<DBShape> {
  // A list of all columns associated with all tables
  tables: {
    [K in keyof DBShape]: Arr<keyof DBShape[K]>;
  };
  // A list of all primary keys associated with the tables
  primaryKeys: Partial<{
    [K in keyof DBShape]: keyof DBShape[K] | Arr<keyof DBShape[K]>;
  }>;
  // A list of all non-primary unique keys for a given table.
  uniqueKeys: Record<string, Arr<string | string[]>>;
}

Using the CLI

TableLoader comes with a utility for generating this introspection from the CLI:

# Shows info about available options
tableloader --help
tableloader --adapter [knex,kysely] --connnection $DATABASE_URL

If you'd prefer not to use the CLI, and use the programmatic API, the introspectionBuilder can be imported from tableloader/dist/introspection, and can be invoked to generate the introspection types at runtime.

See the source for the introspection CLI for an example of how you might create a runtime tool to generate the introspection data.

Generate Types & Create Adapter

With Kysely

Types should be generated using kysely-codegen

import { Kysely /* PostgresDialect, SqliteDialect, etc. */ } from "kysely";
import { AdapterKysely } from "tableloader/adapters/kysely";
// change to wherever the kysely-codegen types are generated, if the output path is specified
import type { DB } from "kysely-codegen";
import { introspection } from "./path/to/introspection";

const kysely = new Kysely<DB>({
  // ... config for specific kysely adapter
});
const adapter = new AdapterKysely(kysely, {
  // camelCase: true
});
const tableLoader = new TableLoader(adapter, {
  introspection,
  relations: {
    // ... relations defined here
  },
});

With Knex

Types should be generated using @tgriesser/schemats v9+

npm run schemats generate --prettier --skipPrefix knex_ -o $OUTPUT_PATH -c $DATABASE_URL
import knex from "knex";
import { AdapterKnex } from "tableloader/adapters/knex";
import type { DBTables } from "./path/to/schemats-output";
import { introspection } from "./path/to/introspection";

const db = knex({
  // ... config for your Knex instance
});
const adapter = new AdapterKnex<DBTables>(db, {
  // camelCase: true
});
const tableLoader = new TableLoader(adapter, {
  introspection,
  relations: {
    // ...relation definitions
  },
});

API

TableLoader

tableLoader.makeContext()

Creates a new "context", where the context is the entry point for all APIs to query for tables & relations. Typically you'll do this once per-request, in a middleware or similar location where you setup state for your

const ctx = tableLoader.makeContext();

const userOne = await ctx.table("users").loadPk(1);

TableLoaderContext

ctx.table()

Given a table name, exposes the TableApi for querying tables.

Promise.all([
  ctx.table("users").loadPk(1),
  ctx.table("users").filter({ status: "inactive" }).getRows(),
]);

ctx.relation()

Returns the "relation" api, where the relation name is defined as the concatenation of the table name and object property

ctx.setSelections()

The setSelections API takes object, containing a mapping of table names, to columns that should be selected for that table. If not specified, the default is to select * for the table when loaded.

Primary & unique keys are always loaded, regardless of the setSelections configuration. This is to ensure that we're able to keep proper row identity, and reduce the number of subsequent row lookups.

ctx.setSelections({
  users: ["name"],
  projects: ["status"],
});

// select id, name from users where id in (?)
const user = await ctx.table("users").loadPk(1);

// select id, slug, status from project where id in (?)
const project = await ctx.table("project").loadPk(1);

ctx.queries

A getter, returning all of the queries issued

TableApi

table.loadPk()

Loads a row for a table by its Primary Key. The introspection metadata is utilized to infer the correct types for the argument.

ctx.table("user").loadPk(1);

If the primary key is composite (multiple columns), these should be provided as an object:

ctx.table("membership").loadPk({ user_id: 1, organization_id: 2 });

The loadPk utilizes the batching via DataLoader, so multiple parallel calls to loadPk will be sent in a single query:

// select "user".* from "user" where "id" in (1, 2, 3)
Promise.all([
  ctx.table("user").loadPk(1),
  ctx.table("user").loadPk(2),
  ctx.table("user").loadPk(3),
]);

Returns the LoadedRow

table.loadPkOrThrow()

Like the loadPk method, except it throw a RowNotFoundError if the record is not found

table.loadPkBatch()

table.loadPkBatchOrThrow()

table.loadPkBatchOrError()

table.loadUniq()

table.loadBy()

table.filter()

table.orderBy()

table.limit()

table.findAll()

table.asConnection()

table.findOne()

table.interpolate()

Takes a plain object, and coerces it into a LoadedRow for the table, caching it in the

LoadedRow

A LoadedRow object is a class which wraps the raw database object, providing getters for the individual fields. It should feel functionally similar to the POJO (plain-old JavaScript Object) returned from the query library, however it has a few unique characteristics specific to TableLoader.

Relation Api (one)

relation.loadFor()

relation.loadForBatch()

Relation Api (many)

relation.orderBy()

relation.filter()

relation.scope()

relation.loadFor()

relation.loadOneFor()

relation.loadForBatch()

relation.asConnectionFor()

Returns an object properly implementing the GraphQL Cursor Connections specification, exposing a "edges", "pageInfo", and "nodes" properties.

const connection = ctx.relation("user:posts").asConnectionFor(user, args);

const [edges, pageInfo, nodes] = await Promise.all([
  connection.edges,
  connection.pageInfo,
  connection.nodes,
]);

relation.asPageFor()

Constraining Queries

Filters

Filters add a where clause to the query.

Scopes

Table Scopes

TableLoader comes with a concept of "scopes", a way to predefine a set of constraints for a query, to be invoked at runtime. Scopes are added to tables, using the adapter's scopes() on a relation definition, adding the scope options as metadata to the relation:

new TableLoader(adapter, {
  introspection,
  relations: {
    user: (t) => ({
      posts: t.hasMany("post"),
    }),
  },
  scopes: adapter.scopes({
    post: {
      public: (qb) => qb.where("published", true).where("hidden", false),
    },
  }),
});

Later when using the relation:

ctx.relation("user:posts").scope("public").loadFor(user);

Global Scopes

TableLoader also comes with a concept of "global scopes". Global scopes are defined using the same adapter.scopes method, and they are automatically used whenever a table is accessed:

new TableLoader(adapter, {
  // ...
  globalScopes: adapter.scopes({
    project: {
      notDeleted: (qb) => qb.whereNull("deleted_at"),
    },
  }),
});

It's possible to remove global scopes, by calling withougGlobalScopes:

ctx.table("project").withoutGlobalScopes().getRows();

Scopes with arguments

Scopes can define arguments:

scopes: {
  user: adapter.scopes({
    active: (qb, excludeUsers: number[]) => {
      return qb.where("activated", true).andWhereNotIn("id", excludeUsers);
    },
  });
}

TableLoader will track the definition of these types and include them in the types for the scope invocation:

// OK
ctx.relation("organization:users").scope("active", [1, 2, 3]).loadFor(org);

// TS error, excludeUsers expected:
ctx.relation("organization:users").scope("active").loadFor(org);

Dynamic scope creation

If you need to create the scopes dynamically, based on the

Aggregates

When aggregating, we assume we're grouping based on the relation column

Tracking Column Usage

Relation Naming Conventions

Naming conventions are similar to other libraries / frameworks, such as Rails, Laravel, or Django. The foreign key of a table is assumed to be the concatenation of the source table's name and the primary key of the referenced table, unless otherwise specified by the .setKeys method.

user
  - id
  - name
  - created_at

post
  - id
  - title
  - user_id
user -> hasMany(post)
post -> belongsTo(user)

One-To-One Association

hasOne

A hasOne relationship defines a one-to-one relationship between the source table and the target table, where the source table is assumed to have exactly one reference to the target table.

({
  user: (t) => ({
    userInfo: t.hasOne("user_info").orThrow,
    optionalMeta: t.hasOne("user_meta"),
  }),
});
select user_info.* from user_info where user_id in (...) -- (user.id)

belongsTo

The belongsTo defines a scenario where the source table refers to another model, by the primary key reference of the target table, via the primary key reference in its own table.

({
  userInfo: (t) => ({
    user: t.belongsTo("user"),
  }),
});

hasOneThrough

The "hasOneThrough" relationship isn't too common, it defines a one-to-one relationship with between a source table and a target table, joined through a third joining table.

({
  session: (t) => ({
    userInfo: t.hasOneThrough("userInfo", "user"),
  }),
});
select user_info.*, user.id as user$$id
inner join user on user.id = user_info.user_id
where user_info.user_id in (...) -- session.user_id

One-To-Many Association

hasMany

A hasMany is a fairly common one-to-many relationship, where the source table has zero or more of the target table. This is defined by default convention where the target table having the source table, suffixed by the source table's primary key.

({
  user: (t) => ({
    posts: t.hasMany("post"),
  }),
});
select post.* from post where user_id in (...) -- (user.id)

hasManyThrough

The hasManyThrough relationship provides a way to access a table through an intermediate table, where the intermediate table is its own entity.

({
  user: (t) => ({
    payments: t.hasManyThrough("payment", "invoice"),
  }),
});
ctx.relation("user:payments").loadFor(user);
select payment.* from payment
inner join invoice on invoice.id = payment.invoice_id
where invoice.user_id in (...) -- (user.id)

Many-To-Many Association

manyToMany

Similar to the belongsToMany in Laravel, or the has_and_belongs_to_many in Rails, the manyToMany defines a relationship between two tables, through a third table, where the intermediate table contains the primary key references to the two tables it's joining.

({
  user: (t) => ({
    projects: t.manyToMany("project", "projectUser"),
  }),
});

Modifying Relation Keys

For any relation, it is possible to modify the keys to suit the relation, such as in a situation where the relation is self referential, or the keys don't map exactly to the standard naming assumptions

({
  project: {
    // a project can have a template, which refers to the same table
    template: t.belongsTo("project").setKeys({ sourceKey: "template_id" }),
  },
});
select project.* from project where id in (...) -- source.template_id

Errors

TableLoaderError

RelationNotFoundError

RowNotFoundError

ColumnNotLoadedError

TODO Before Launch

  • [x] CamelCase handling
  • [x] Pluralization handling
  • [ ] Aggregation API
  • [ ] Pagination API Configuration
  • [ ] Example of setSelections use
  • [ ] Api for detecting column usage
  • [x] Decide on relation types
  • [ ] Opt-out for union all
  • [ ] Scopes (global, local)
  • [ ] Cleanup cache key structure to read a bit better
  • [ ] Finish off Docs
  • [ ] Benchmarks

Roadmap:

  • [ ] Consider pulling more introspection info, nullability & foreign keys
  • [ ] Windowing
  • [ ] Configuring when to use union vs. not

License

MIT