Joshua T Kalis (he/him/his)

I build great teams and excellent software.

JSON Query

10 Nov 2023

I had a relational dataset in JSON format that I wanted to be able to query like SQL but could not find a package that would did what I was looking for. So... I built it.

const isFn = (arg) => /function/i.test({}.toString.call(arg));
const isAr = (arg) => /array/i.test({}.toString.call(arg));

function addQueries(root) {
  function withQueries(table) {
    return Object.defineProperties(table, {
      inner: {
        enumerable: false,
        value(prop, fn) {
          const working = structuredClone(table);

          working.map((item) => {
            item[prop] = fn(withQueries(item[prop]));

            return item;
          });

          return working;
        },
      },
      join: {
        enumerable: false,
        value(...args) {
          let result;

          if (args.length > 2 || args.length < 1) {
            throw new Error(
              `Invalid number of arguments, expected 1 or 2; received "${args.length}"`
            );
          } else if (args.length === 2) {
            if (!isAr(args[0]) || !isAr(args[1])) {
              throw new Error(
                `Expected two string arrays "[["table_name", "id_column"], ["table_name", "id_column"]]"; provided ${args}`
              );
            }

            const [[table1, column1], [table2, column2]] = args;

            result = structuredClone(table).map((sourceItem) => {
              sourceItem[table2] = root[table1]
                .filter(({ [column1]: v1 }) => sourceItem[column1] === v1)
                .map(({ [column2]: v2 }) => {
                  return structuredClone(
                    root[table2].find((joinItem) => joinItem[column2] === v2)
                  );
                });

              return sourceItem;
            });
          } else {
            if (!isAr(args[0])) {
              throw new Error(
                `Expected string array "[["table_name", "id_column"]]"; provided ${args}`
              );
            }

            const [[table1, column1]] = args;

            result = structuredClone(table).map((sourceItem) => {
              sourceItem[table1] = root[table1].filter(
                ({ [column1]: v1 }) => sourceItem[column1] === v1
              );

              return sourceItem;
            });
          }

          return withQueries(result);
        },
      },
      where: {
        enumerable: false,
        value: (col, fn) =>
          withQueries(
            table.filter(({ [col]: val }) => (isFn(fn) ? fn(val) : fn === val))
          ),
      },
    });
  }

  return [
    root,
    structuredClone,
    Object.entries,
    (all) => all.map(([key, val]) => [key, withQueries(val)]),
    Object.fromEntries,
  ].reduce((acc, fn) => fn(acc));
}

This function withQueries enables the types of interactions I was looking for by recursively applying itself to its results.

Given a simple data structure of relational data in JSON format that dataset can be queried fairly simply.

const db = addQueries({
  accounts: [
    { account_id: 1234, name: "foo" },
    { account_id: 5678, name: "bar" },
    { account_id: 1111, name: "qux" },
  ],
  accounts_users: [
    { account_id: 1234, user_id: 1 },
    { account_id: 1234, user_id: 2 },
    { account_id: 5678, user_id: 2 },
    { account_id: 5678, user_id: 3 },
  ],
  notes: [
    { user_id: 1, note: "Hello" },
    { user_id: 1, note: "world" },
    { user_id: 3, note: "lorem" },
    { user_id: 3, note: "ipsum" },
    { user_id: 3, note: "dolor" },
    { user_id: 3, note: "sit" },
    { user_id: 3, note: "amet" },
  ],
  users: [
    { user_id: 1, name: "Happy" },
    { user_id: 2, name: "little" },
    { user_id: 3, name: "cloud" },
  ],
});

const filterFunction = (val) => /(.)\1+/.test(val.toString());

// simple value filter
db.accounts.where("name", "foo");

// complex value filter
db.accounts.where("account_id", filterFunction);

// inner join 2 tables (one-to-many)
db.users.join(["notes", "user_id"]);

// inner join 3 tables (many-to-many)
db.accounts.join(["accounts_users", "account_id"], ["users", "user_id"]);

// join everything; from the perspective of users
db.users
  .join(["notes", "user_id"])
  .join(["accounts_users", "user_id"], ["accounts", "account_id"]);

// join everything; from the perspective of accounts
db.accounts
  .join(["accounts_users", "account_id"], ["users", "user_id"])
  .inner("users", (user) => user.join(["notes", "user_id"]));