A simple, easy to use and extendible JSON database.

Overview

newtondb
Newton

⚠️ This package is under active development: Compatibility and APIs may change.

A simple, easy to use and extendible JSON database.

Build status Version Minzipped Size License

twitter github youtube linkedin

Table of contents

Introduction

JSON is central to Javascript and Typescript development - it's commonly used when you need to transfer data between one medium and another, such as when you're consuming and sending data to and from APIs and when persisting and hydrating application data to a remote store (be it the file system, an S3 bucket, session storage, local storage, etc.)

Most of the time, Javascript's Object and Array prototype methods are sufficient when interfacing with your data, however there are times when you may need to interface with a JSON data source as you might a more traditional database. You might find yourself needing to:

  • Performantly query large data sets (of which arrays are notoriously poor for).
  • Safely execute serializable queries (e.g. user defined queries).
  • Safely execute data transformations.
  • Set up observers to listen to changes in your data.
  • Automatically hydrate from and persist changes to a remote store (e.g. file system, local storage, s3 bucket, etc.)

That's where Newton can help out.

Key features

Although Newton doesn't aim to replace a traditional database, it does borrow on common features to let you interact with your data more effectively. It does this by providing:

  • A serializable query language to query your data.
  • Adapters to read and write to commonly used stores (filesystem, s3, local/session storage, etc.)
  • Indices - primary, secondary and sort indexes to improve the efficiency of reads.
  • Serializable data transformations.
  • Query caching, eager/lazy loading.
  • Transactions.
  • Observers (hooks).

and more. Above everything else, Newton's mission is to allow you to interface with your data while optimizing for performance and extendability.

Installation

Using npm:

$ npm install newtondb

Or with yarn:

$ yarn add newtondb

Basic usage

Using a single collection:

import { Database } from "newtondb";

const scientists = [
  { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" },
  { name: "Albert Einstein", born: "1879-03-14T12:00:00.000Z" },
];
const db = new Database(scientists);

db.$.get({ name: "Isaac Newton" }).data;

// => { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" }

Using multiple collections:

import { Database } from "newtondb";

const db = {
  scientists: [
    { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" },
    { name: "Albert Einstein", born: "1879-03-14T12:00:00.000Z" }
  ],
  universities: [
    { name: "University of Zurich", location: "Zurich, Switzerland" }
  ]
];
const db = new Database(db);

db.$.universities.get({ location: "Zurich, Switzerland" }).data;

// => { name: "University of Zurich", location: "Zurich, Switzerland" }

Basic principles

Adapters

An Adapter is what Newton uses to read and write to a data source. In simple terms, an Adapter is merely an instance of class with both a read and write method to be able to read from and write changes to your data source.

When instantiating Newton, you can either pass through an explicit instance of an Adapter, or you can pass through your data directly and Newton will attempt to infer and instantiate an adapter on your behalf using the following rules:

  • If an array of objects, or an object whose properties are all arrays is passed through, Newton will instantiate a new MemoryAdapter instance.
  • If a file path is passed through, Newton will instantiate a new FileAdapter instance.

You can extend Newton by creating your own Adapters and passing instances of those adapters through when you instantiate Newton.

Collections

When thinking of data sources expressed in JSON, you will often have arrays/lists of data objects of a given type:

[
  { "name": "Isaac Newton", "born": "1643-01-04T12:00:00.000Z" },
  { "name": "Albert Einstein", "born": "1879-03-14T12:00:00.000Z" }
]

We define this as a Collection, where a Collection can have a type (in the above example we define a Collection of type Scientist).

One might also have a JSON data structure that defines various arrays of data of different types:

{
  "scientists": [
    { "name": "Isaac Newton", "born": "1643-01-04T12:00:00.000Z" },
    { "name": "Albert Einstein", "born": "1879-03-14T12:00:00.000Z" }
  ],
  "universities": [
    { "name": "University of Zurich", "location": "Zurich, Switzerland" }
  ]
}

We define this as a Database which contains two collections:

  1. scientists of type Scientist
  2. universities or type University

Newton will take as input either a single Collection or a Database with one or more collections.

Indexing

Newton operates on arrays/lists of data. However, there are performance implications when operating on arrays that starts to become more troublesome as the size of your dataset grows. Namely, when given a query or a predicate, internally you have to iterate over the entire list to determine which objects match your predicate.

Newton solves this by internally maintaining both a linked list and a set of hash maps to efficiently query your data.

For example, most data sources will often have a primary key composed of one or more attributes that uniquely identifies the item:

[
  { "code": "isa", "name": "Isaac Newton", "university": "berlin" },
  { "code": "alb", "name": "Albert Einstein", "university": "cambridge" }
]

When instantiating newton, if you set the primaryKey configuration option to ["code"], a hash map would be created internally with code as the key, so that when you were to query it, newton could return the record from a single map lookup rather than iterating over the entire list:

$.get("isa").data;

// => { "code": "isa", "name": "Isaac Newton", "university": "berlin" }

You can configure one or more secondary indexes to maintain hashmaps for attributes that are commonly queried. For example, if you had a data source of 20,000 scientists, and you often queried against universities, you may want to create a secondary index for the university attribute. When you then executed the following query:

$.find({ university: "berlin", isAlive: true });

Rather than iterating over all 20,000 records, newton would instead iterate over the records in the hashmap with university as the hash (in which there might only be 100 records). You can set up multiple secondary indexes to increase performance even more as your dataset grows.

Chaining

Newton functions using a concept of operation chaining, where the data output from one operation feeds in as input to the subsequent operation. For example, when updating a record, Newton's update function doesn't take as input a query of records to update against. Rather, if you wanted to update a set of records that matched a particular query, you would first find those records and then call set:

// update all records where "university" = 'berlin' to "university" = 'University of Berlin'
$.find({ university: "berlin" }).set({ university: "University of Berlin" });

This allows you to set up complex chains and transformations on your data.

Committing mutations

Mutations are only persisted to the original data source when .commit is called on your chain.

$.scientists
  .find({ university: "berlin" })
  .set({ university: "University of Berlin" }).data;

// => [ { "code": "isa", "name": "Isaac Newton", "university": "University of Berlin" } ]

In the above example, the university attribute for all scientists studying at the "berlin" university is set to "University of Berlin", and you can access that data through the .data property. However, if you were to then query for scientists attending the "University of Berlin" you would receive an empty result:

$.scientists.find({ university: "University of Berlin" }).data;

// => []

In order to persist mutations within your chain to the original data source, you must call .commit:

$.scientists
  .find({ university: "berlin" })
  .set({ university: "University of Berlin" })
  .commit(); // commits the mutations defined in the chain

You can then query against the updated items:

$.scientists.find({ university: "University of Berlin" }).count;

// => 1

Adapters

MemoryAdapter

Reads an object directly from memory.

Usage:

import { Database } from "newtondb";
import { MemoryAdapter } from "newtondb/adapters/memory-adapter";

const adapter = new MemoryAdapter({
  scientists: [
    { code: "isa", name: "Isaac Newton", university: "berlin" },
    { code: "alb", name: "Albert Einstein", university: "cambridge" },
  ],
  universities: [
    { id: "berlin", name: "University of Berlin" },
    { id: "cambridge", name: "University of Cambridge" },
  ],
});

const db = new Database(adapter);
await db.read();

db.$.scientists.find({ code: "isa" });

// => { code: "isa", name: "Isaac Newton", university: "berlin" }

FileAdapter

Reads a JSON file from the local filesystem.

Usage:

import { Database } from "newtondb";
import { FileAdapter } from "newtondb/adapters/file-adapter";

const adapter = new FileAdapter("./db.json");
const db = new Database(adapter);
await db.read();

db.$.scientists.find({ code: "isa" });

// => { code: "isa", name: "Isaac Newton", university: "berlin" }

Database

new Database(adapter, options)

Instantiates a new collection newton instance. The first argument takes either an Adapter instance, or a data object (in which case newton will instantiate a MemoryAdapter on your behalf).

Instantiating with an adapter:

import { Database } from "newtondb";
import { FileAdapter } from "newtondb/adapters/file-adapter";

const adapter = new FileAdapter("./db.json");
const db = new Database(adapter);
await db.load();

Instantiating with a data object:

import { Database } from "newton";

const db = new Database({
  scientists: [
    // ...
  ]
  universities: [
    // ...
  ]
});

Newton can be instantiated with either a single collection, or multiple collections. A single collection is defined by an array of objects of the same type, whereas multiple collections is defined as an object whose properties each contain an array of the same type. See: using multiple collections.

Options

The following options can be passed through to Newton:

Option Type Required Default value Description
writeOnCommit boolean false true If true, Newton will call the write() method on your adapter after each commit, persisting data mutations to your data source. Note: this option is ignored when using the MemoryAdapter.
collection object false {} Can be used to configure each collection. See below.

DatabaseCollectionOptions

When Newton is instantiated with a single collection, the DatabaseCollectionOptions object is a single instance of the CollectionOptions object. For example:

Setting collection options for a single collection:

const scientists = [
  { code: "isa", name: "Isaac Newton", university: "berlin" },
  { code: "alb", name: "Albert Einstein", university: "cambridge" },
];

const db = new Database(scientists, {
  collection: {
    primaryKey: "code",
  },
});

When instantiating Newton with multiple collections, the collection option takes the shape of an object whose properties are the same as your database shape, where each value is an instance of CollectionOptions object. For example:

Setting collection options when using multiple collections:

const scientists = [
  { code: "isa", name: "Isaac Newton", university: "berlin" },
  { code: "alb", name: "Albert Einstein", university: "cambridge" },
];

const universities = [
  { name: "University of Zurich", location: "Zurich, Switzerland" },
];

const db = new Database(scientists, {
  collection: {
    scientists: {
      primaryKey: "code",
    },
  },
});

You can configure as many collections as you like. When omitted from your options, each collection uses the default settings ({}):

const db = new Database(scientists, {
  collection: {
    scientists: {
      primaryKey: "code",
    },
    universities: {
      primaryKey: ["name", "location"],
    },
  },
});

.read()

When reading your data from any source other than memory, you must call .read() before you can interact with your database. read() is an asynchronous function that returns a Promise when complete:

import { Database } from "newtondb";
import { FileAdapter } from "newtondb/adapters/file-adapter";

const db = new Database(new FileAdapter("./db.json"));
await db.read();

// can now interact with your db

In addition to loading your data, read() triggers some basic bootstrapping of your collections. If you try to interact with your database prior to calling read, a NotReadyError exception will be thrown:

import { Database } from "newtondb";
import { FileAdapter } from "newtondb/adapters/file-adapter";

const db = new Database(new FileAdapter("./db.json"));
db.$.find({ name: "isaac newton" }); // will throw a NotReadyError exception

.write()

Will write the current state of your database to its source by triggering the write() method in the Adapter you instantiated the database with. Returns a Promise which will resolve to true when the write operation was successful and false when it was unsuccessful.

const db = new Database(new FileAdapter("./db.json"));
await db.read();

db.find({ name: "isaac newton" }).set({ alive: false }).commit();
await db.write();

When newton is instantiated with writeOnCommit set to true (the default option), commits will automatically be written:

const db = new Database(new FileAdapter("./db.json"), { writeOnCommit: true });
await db.read();

db.find({ name: "isaac newton" }).set({ alive: false }).commit();

// .write() is not necessary as the changes would have already been written

.$

When newton is instantiated with a single collection, $ will return that collection instance:

Instantiating with a single collection:

const db = new Database(scientists);
db.$.find({ name: "isaac newton" });

When instantiated with multiple collections, $ will return an object whose values are collection instances:

const db = new Database({ scientists, universities });

db.$.scientists.find({ name: "isaac newton" });
db.$.universities.find({ name: "university of berlin" });

.data

Returns the data of the entire database.

const scientists = [
  { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" },
  { name: "Albert Einstein", born: "1879-03-14T12:00:00.000Z" },
];

const db = new Database(scientists);
db.$.find({ name: "Isaac Newton" })
  .set({ name: "Isaac Newton (deceased)" })
  .commit();

db.data;

Which returns:

[
  { "name": "Isaac Newton (deceased)", "born": "1643-01-04T12:00:00.000Z" },
  { "name": "Albert Einstein", "born": "1879-03-14T12:00:00.000Z" }
]

.observe()

Sets up an observer which is triggered whenever CRUD operations occur on the database.

⚠️ Note: when configuring an observer at the database level, it is triggered each time any collection is updated. You can also configure observers on individual collections.

When instantiating a database with a single collection, the observe() method expects a function with a single argument of type MutationEvent:

MutationEvent:

type MutationEvent<T> = InsertEvent<T> | DeleteEvent<T> | UpdateEvent<T>;

MutationEvent is either an instance of InsertEvent, DeleteEvent or UpdateEvent:

type InsertEvent<T> = { event: "insert"; data: T };
type DeleteEvent<T> = { event: "delete"; data: T };
type UpdateEvent<T> = { event: "updated"; data: { old: T; new: T } };

When using typescript, you can narrow in on the data using the event name:

const db = new Database(scientists);
db.observe(({ event, data }) => {
  if (event === "insert") {
    // data will be of type `T` (`Scientist` in our case)
  }
});

When instantiating a database with multiple collections, observe() expects a function with two arguments, the first being the collection name and the second a MutationEvent argument:

const db = new Database({ scientists, universities });

db.observe((collection, event) => {
  console.log(`collection ${collection} triggered an event`);
});

Return

observe() returns a numeric ID of the observer. You can pass this ID to unobserve() to cancel the observer.

.unobserve()

Takes as input a numeric ID (the output from observe()) and cancels an observer.

const db = new Database({ scientists, universities });

const observer = db.observe((collection, event) => {
  console.log(`collection ${collection} triggered an event`);

  db.unobserve(observer); // cancel after the first event
});

Throws an ObserverError exception when the observer is not found.

Collections

new Collection(options)

Instantiates a new collection instance. The following options are supported:

Option Type Required Default value Description
primaryKey string | string[] false undefined A single property (or an array of properties for a composite key) that is used to uniquely identify a record. (id is commonly used). Not required, but will dramatically speed up read operations when querying by primary key.
copy boolean false false When mutations are committed using commit, the original data object will be updated. This can sometimes lead to unintended side effects (when using the MemoryAdapter). Set copy to true to create a deep copy of the collection data on instantiation.

.get()

Returns a single record. Most commonly used when querying your collection by a unique identifier:

$.get({ code: "isa" }).data;

// => { "code": "isa", "name": "Isaac Newton", "university": "berlin" }

When your collection has been instantiated with a primary key, and your primary key is a single property whose value is a scalar (e.g. a string or a number), you can call .get with that scalar value and Newton will infer the fact that you're querying against your primary key:

$.get("isa").data;

// => { "code": "isa", "name": "Isaac Newton", "university": "berlin" }

You can query using a primary key, a basic condition, an advanced condition or a function.

.find()

Returns multiple records:

$.find({ university: "cambridge" }).data;

// => [ { "code": "alb", "name": "Albert Einstein", "university": "cambridge" } ]

Will return an empty array when no results are found.

You can query using a primary key, a basic condition, an advanced condition or a function.

.data

The data property returns an array of data as it currently exists within your chain. For example, referencing .data on the root collection will return an array of all data in your collection:

$.data;

// => [ { "name": "Isaac Newton", "born": "1643-01-04T12:00:00.000Z" }, ... ]

When you start chaining operations, .data will return an array of data as it currently exists within your chain:

$.find({ name: "Isaac Newton" }).data;

// => [ { "name": "Isaac Newton", "born": "1643-01-04T12:00:00.000Z" } ]

.count

The count property returns the amount of records currently within your chain. When executed from the base collection, it will return the total amount of records in your collection:

$.count;

// => 100

When you start chaining operations, .count will return the amount of records that currently exist within your chain:

$.find({ name: "Isaac Newton" }).count;

// => 1

.exists

The exists property is a shorthand for .count > 0 and simply returns true or false if there is a non-zero amount of items currently within your chain:

$.get("isa").exists;

// => true

Or when it doesn't exist:

$.get("not isaac newton").exists;

// => false

.select()

By default, when a query returns records, the result includes all of those records' attributes. To only return a subset of an object's properties, call .select with an array of properties to return:

$.get({ name: "Isaac Newton" }).select(["university"]).data;

// => { university: "Cambridge" }

Given the result of one operation is fed into another, the order of select doesn't matter. The above will produce the same output as:

$.select(["university"]).get({ name: "Isaac Newton" }).data;

// => { university: "Cambridge" }

.insert()

Inserts one or more records into the database.

Inserting a single record:

$.insert({
  name: "Nicolaus Copernicus",
  born: "1473-02-19T12:00:00.000Z",
}).commit();

You can insert multiple records by passing through an array of objects to insert:

$.insert([
  { name: "Nicolaus Copernicus", born: "1473-02-19T12:00:00.000Z" },
  { name: "Edwin Hubble", born: "1989-11-10T12:00:00.000Z" },
]).commit();

.set()

Updates a set of attributes on one or more records.

// update isaac newton's college to "n/a" and set isAlive to false
$.find({ name: "Isaac Newton" })
  .set({ college: "n/a", isAlive: false })
  .commit();

set can also take as input a function whose first argument is the current value of the record, and which must return a subset of the record to update:

// uppercase all universities using .set
$.set(({ university }) => ({
  university: university.toUpperCase(),
})).commit();

⚠️ this differs from replace() in that it will only update/set the attributes passed through, whereas replace() will replace the entire document.

.replace()

Replaces an entire document with a new document:

const newNewton = {
  name: "Isaac Newton",
  isAlive: false,
  diedOn: "1727-03-31T12:00:00.000Z",
};

$.get("Isaac Newton").replace(newNewton).commit();

replace can also take as input a function whose first argument is the current value of the record, and which must return a complete new record:

// uppercase all universities using .replace
$.replace((record) => ({
  ...record,
  university: university.toUpperCase(),
})).commit();

.delete()

Deletes one or more records from the collection.

delete() doesn't take any arguments. Rather, it deletes the records that currently exist within the chain at the time that it's called. For example:

// delete all records from a collection
$.delete().commit();

// delete all scientists from cambridge university
$.find({ university: "cambridge" }).delete().commit();

// delete a single record
$.get("isaac newton").delete().commit();

.orderBy()

orderBy can be used to sort records by one or more properties. It takes as input a single object whose properties are a key of your collection's properties, and whose value is either asc (for ascending) or desc (for descending).

For example, using the below dataset:

const students = [
  { name: "roger galilei", university: "mit" },
  { name: "kip tesla", university: "harvard" },
  { name: "rosalind faraday", university: "harvard" },
  { name: "thomas franklin", university: "mit" },
  { name: "albert currie", university: "harvard" },
];

To sort by university in descending order and name in ascending order:

$.orderBy({ university: "desc", name: "asc" }).data;

This will produce the following:

[
  { "name": "roger galilei", "university": "mit" },
  { "name": "thomas franklin", "university": "mit" },
  { "name": "albert currie", "university": "harvard" },
  { "name": "kip tesla", "university": "harvard" },
  { "name": "rosalind faraday", "university": "harvard" }
]

Given the order by which you sort is important, orderBy() will adhere to the order of the properties in the object passed through.

For example, in the above example, { university: "desc", name: "asc" } was passed through. orderBy would first sort by university in descending order, and then by name in ascending order.

If you were to instead pass through { name: "asc", university: "desc" }, orderBy would first sort by name in ascending order and then by university in descending order. This would produce a different result:

[
  { "name": "albert currie", "university": "harvard" },
  { "name": "kip tesla", "university": "harvard" },
  { "name": "roger galilei", "university": "mit" },
  { "name": "rosalind faraday", "university": "harvard" },
  { "name": "thomas franklin", "university": "mit" }
]

.limit()

You can use limit to only return the first n amount of records within your chain:

$.find({ university: "cambridge" }).limit(5).data;

Will return the first 5 records with university set to "cambridge".

You can use limit with offset to implement an offset based pagination on your data.

.offset()

offset will skip the first n records from your query. For example, to skip the first 5 records:

$.find({ university: "cambridge" }).offset(5).data;

offset can be used with limit to implement an offset based pagination:

const pageSize = 10;
const currentPage = 3;

$.find()
  .limit(pageSize)
  .offset((currentPage - 1) * pageSize).data;

.commit()

The following operations can mutate (change) your data:

Mutations will only be persisted/committed to your collection when .commit() is called. This is useful as it allows you to:

  1. Perform temporary transformations on your data, and
  2. Create complex chains

What's more, by requiring a call to commit Newton confirms your intent to mutate the original data source, reducing the risk for unintended side effects throughout your application.

.assert()

Runs an assertion on your chain, and continues the chain execution if the assertion passes and raises an AssertionError when it fails.

Takes as input a function whose single argument is the chain instance and which returns a boolean:

import { AssertionError } from "newtondb";

try {
  $.get({ name: "isaac newton" })
    .assert(({ exists }) => exists)
    .set({ university: "unknown" })
    .commit();
} catch (e: unknown) {
  if (e instanceof AssertionError) {
    // record does not exist
  }
}

You can optionally pass through a string as the first argument and a function as the second to describe your assertion:

import { AssertionError } from "newtondb";

try {
  $.get({ name: "isaac newton" })
    .assert(
      "the record the user is attempting to update exists",
      ({ exists }) => exists
    )
    .set({ university: "unknown" })
    .commit();
} catch (e: unknown) {
  if (e instanceof AssertionError) {
    // record does not exist
  }
}

.observe()

When mutations to the data source are committed, one or more of the following events will be raised:

  • insert: raised when a record is inserted into the collection
  • delete: raised when a record is deleted from the collection
  • updated: raised when a record is updated

You can pass callbacks to the observe method that will be triggered when these events occur.

On insert:

const onInsert = $.observe("insert", (record) => {
  //
});

On delete:

const onDelete = $.observe("delete", (record) => {
  //
});

On update:

const onUpdate = $.observe("updated", (record, historical) => {
  // historical.old = item before update
  // historical.new = item after update
});

You can also pass through a wildcard observer which will be triggered on every event:

const wildcardObserver = $.observe((event, data) => {
  // event: "insert" | "delete" | "updated"
  // data: event data
});

Calls to .observe() will return an numeric id of the observer. This id should be passed to unobserve() to cancel the observer.

.unobserve()

Cancels an observer set with the .observe() method. Takes as input a numeric ID (which should correspond to the output of the original .observe call).

Querying

Newton allows you to query your data using through the following mechanisms:

The examples in this section will use the following dataset:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false },
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]

By primary key

When you instantiate Newton you can optionally define a primary key:

const db = new Database(scientists, { primaryKey: "id" });

⚠️ Performance warning: while primaryKey is optional, it is highly recommended you set this when you instantiate Newton in order to optimize read performance.

If the value of your primary key is a scalar value (string or number), you can query your collection by the value directly:

$.get(2).data;

// =>  { id: 3, name: 'galileo galilei', born: 1564, alive: false }

If you are using a composite primary key, you'll have to pass through an object:

const $ = new Collection(scientists, { primaryKey: ["name", "born"] });

$.get({ name: "albert einstein", born: 1879 }).data;

// => { "id": 2, "name": "albert einstein", "born": 1879, "alive": false }

By function

A function predicate can be passed to get() and find(), which takes as input a single argument with the record, and should return true if the record passes the predicate and false if not.

For example, to return scientists who are currently alive:

$.find((record) => record.alive).data;

This will return the following:

[
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]

⚠️ Optimization warning: Newton will have to iterate over each item in your collection to test whether or not the predicate is truthy. Where possible, you should try and use a basic or advanced condition with secondary indexes to optimize read operations.

By basic condition

You can pass a simple key-value query to perform an exact match on items in your collection:

$.find({ alive: true }).data;

Which returns:

[
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]

You can pass multiple properties through:

$.find({ alive: true, born: 1920 }).data;

// => [ { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true } ]

By advanced condition

An advanced condition is an object with contains a property, an operator and a value:

$.find({
  property: "born",
  operator: "greaterThan",
  value: 1900,
}).select(["name", "born"]).data;

// => [ {"name":"roger penrose","born":1931}, {"name":"rosalind franklin","born":1920} ]

every and some

You can create complex conditions by using a combination of some and every. Both properties accept an array of conditions. some will evaluate as true if any condition within the array evaluates as true, whereas every will evaluate to true only when all conditions within the array evaluate as true.

every

You can use every to return all records that meet all of the conditions:

$.find({
  every: [
    { property: "born", operator: "greaterThan", value: 1800 },
    { property: "name", operator: "startsWith", value: "r" },
  ],
}).select(["name", "born"]).data;

This query will return all scientists who were born after the year 1800 and whose name starts with the letter r:

[
  { "name": "roger penrose", "born": 1931 },
  { "name": "rosalind franklin", "born": 1920 }
]
some

You can use some to return all records that meet any of the conditions:

$.find({
  some: [
    { property: "born", operator: "greaterThan", value: 1800 },
    { property: "name", operator: "startsWith", value: "a" },
  ],
}).select(["name", "born"]).data;

This query will return all scientists who were born after the year 1800 or whose name starts with the letter a:

[
  { "name": "albert einstein", "born": 1879 },
  { "name": "marie curie", "born": 1867 },
  { "name": "roger penrose", "born": 1931 },
  { "name": "rosalind franklin", "born": 1920 }
]
Nesting conditions

You can nest conditions to create complex rules:

$.find({
  some: [
    {
      every: [
        { property: "born", operator: "greaterThan", value: 1800 },
        { property: "alive", operator: "equal", value: true },
      ],
    },
    {
      some: [
        { property: "name", operator: "startsWith", value: "albert" },
        { property: "name", operator: "endsWith", value: "newton" },
      ],
    },
  ],
}).select(["name", "born"]).data;

This query will return all scientists where:

  1. They were born after the year 1800 and are alive, or
  2. Whose first name starts with "albert" or ends with "newton":
[
  { "name": "isaac newton", "born": 1643 },
  { "name": "albert einstein", "born": 1879 },
  { "name": "roger penrose", "born": 1931 },
  { "name": "rosalind franklin", "born": 1920 }
]

Operators

Conditions require one of the following operators:

equal

Performs a strict equality (===) match:

$.find({ property: "born", operator: "equal", value: 1643 }).data;

Returns the following:

[{ "id": 1, "name": "isaac newton", "born": 1643, "alive": false }]
notEqual

Performs a strict inequality (!==) match:

$.find({ property: "alive", operator: "notEqual", value: true }).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false }
]
startsWith

Checks if a string starts with a given value.

$.find({ property: "name", operator: "startsWith", value: "ro" }).data;

Returns the following:

[
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
endsWith

Checks if a string ends with a given value.

$.find({ property: "name", operator: "endsWith", value: "n" }).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
greaterThan

Checks if a numeric value is greater than a given value:

$.find({ property: "born", operator: "greaterThan", value: 1879 }).data;

Returns the following:

[
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
greaterThanInclusive

Checks if a numeric value is greater than or equal to a given value:

$.find({ property: "born", operator: "greaterThanInclusive", value: 1879 })
  .data;

Returns the following:

[
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
lessThan

Checks if a numeric value is less than than a given value:

$.find({
  property: "born",
  operator: "lessThan",
  value: 1867,
}).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false }
]
lessThanInclusive

Checks if a numeric value is less than than or equal to a given value:

$.find({
  property: "born",
  operator: "lessThanInclusive",
  value: 1867,
}).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false }
]
in

Checks if a value exists within an array of allowed values:

$.find({ property: "born", operator: "in", value: [1867, 1920] }).data;

Returns the following:

[
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
notIn

Checks if a value does not exist within an array of values:

$.find({ property: "born", operator: "notIn", value: [1867, 1920] }).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true }
]
contains

We'll use the following dataset for this example (as well as the examples in doesNotContain):

[
  {
    "name": "lise meitner",
    "awards": ["leibniz medal", "liebenn prize", "ellen richards prize"]
  },
  {
    "name": "vera rubin",
    "awards": [
      "gruber international cosmology prize",
      "richtmyer memorial award"
    ]
  },
  {
    "name": "chien-shiung wu",
    "awards": ["john price wetherill medal"]
  }
]

Checks if an array or string contains a value:

$.find({ property: "name", operator: "contains", value: "-" }).data;

Returns the following:

[{ "name": "chien-shiung wu", "awards": ["john price wetherill medal"] }]

contains can also be used to check if an array contains a given value:

$.find({
  property: "awards",
  operator: "contains",
  value: "richtmyer memorial award",
}).data;

Returns the following:

[
  {
    "name": "vera rubin",
    "awards": [
      "gruber international cosmology prize",
      "richtmyer memorial award"
    ]
  }
]
doesNotContain

Checks if an array or string does not contain a value:

$.find({ property: "name", operator: "doesNotContain", value: "r" }).data;

Returns the following:

[{ "name": "chien-shiung wu", "awards": ["john price wetherill medal"] }]

doesNotContain can also be used to check if an array does not contain a given value:

$.find({
  property: "awards",
  operator: "doesNotContain",
  value: "richtmyer memorial award",
}).data;

Returns the following:

[
  {
    "name": "lise meitner",
    "awards": ["leibniz medal", "liebenn prize", "ellen richards prize"]
  },
  { "name": "chien-shiung wu", "awards": ["john price wetherill medal"] }
]
matchesRegex

Checks if a string matches a regular expression:

$.find({
  property: "name",
  operator: "matchesRegex",
  value: "^ro(g|s)",
}).data;

Returns the following:

[
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]
doesNotMatchRegex

Checks if a string does not match a regular expression:

$.find({
  property: "name",
  operator: "doesNotMatchRegex",
  value: "^ro(g|s)",
}).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false }
]

Preprocessors

Preprocessor functions can optionally be applied to the values you're evaluating against in your condition. They can be used to check for:

  • case insensitivity
  • empty/non-empty checks
  • type coercion

To apply a preprocessor to a property, instead of passing a property through as a string, pass an object through with a name and preProcess property:

$.find({
  property: { name: "name", preProcess: ["toUpper"] },
  operator: "contains",
  value: "ISAAC",
}).data;

// => [ { id: 1, name: 'isaac newton', born: 1643, alive: false } ]

preProcess is an array which can contain one or more preprocessors. When a preprocessor doesn't require any arguments (toUpper, toLower, toString, toNumber, toLength) you can pass the preprocessor through as a string (as shown in the above example). For functions that require one or more arguments (substring, concat), pass through an object where fn is the name of the preprocessor and args is an array of arguments:

$.find({
  property: {
    name: "name",
    preProcess: ["toUpper", { fn: "substring", args: [0, 3] }],
  },
  operator: "equal",
  value: "ISA",
}).data;

// => [ { id: 1, name: 'isaac newton', born: 1643, alive: false } ]
toUpper

Converts the property to all uppercase before evaluating the condition.

$.find({
  property: { name: "name", preProcess: ["toUpper"] },
  operator: "equal",
  value: "ISAAC",
}).data;

// => [ { id: 1, name: 'isaac newton', born: 1643, alive: false } ]
toLower

Converts the property to all lowercase before evaluating the condition.

$.find({
  property: { name: "name", preProcess: ["toLower"] },
  operator: "equal",
  value: "isaac",
}).data;

// => [ { id: 1, name: 'isaac newton', born: 1643, alive: false } ]
toString

Converts the property to a string before evaluating the condition.

The below example won't return any data since born is of type number on the original object and we are doing a comparison of type string (remembering that the equal operators perform a strict equality (===) check):

$.find({ property: "born", operator: "equal", value: "1867" }).data;

// => []

If you want to compare a number against a string value, you can coerce the original value to a string using the toString preprocessor:

$.find({
  property: { name: "born", preProcess: ["toString"] },
  operator: "equal",
  value: "1867",
}).data;

// => [ { id: 4, name: 'marie curie', born: 1867, alive: false } ]
toNumber

Converts the property to a number before evaluating the condition.

Using the following dataset:

[
  { "element": "hydrogen", "atomicNumber": "1" },
  { "element": "helium", "atomicNumber": "2" },
  { "element": "lithium", "atomicNumber": "3" },
  { "element": "beryllium", "atomicNumber": "4" },
  { "element": "boron", "atomicNumber": "5" }
]

Executing the following query will return an empty result, as we are trying to perform an equal operation (===) on data of type string with a number:

$.find({ property: "atomicNumber", operator: "equal", value: 2 }).data;

// => []

If we want to perform an equality match on different data types, we can first coerce the value to a number:

If you want to compare a number against a string value, you can coerce the original value to a string using the toString preprocessor:

$.find({
  property: { name: "atomicNumber", preProcess: ["toNumber"] },
  operator: "equal",
  value: 2,
}).data;

// => [ { element: 'helium', atomicNumber: '2' } ]
toLength

Returns the length of a string or the amount of items in an array. Can be used to check for non-empty values:

$.find({
  property: { name: "name", preProcess: ["toLength"] },
  operator: "greaterThan",
  value: 0,
}).data;

Returns the following:

[
  { "id": 1, "name": "isaac newton", "born": 1643, "alive": false },
  { "id": 2, "name": "albert einstein", "born": 1879, "alive": false },
  { "id": 3, "name": "galileo galilei", "born": 1564, "alive": false },
  { "id": 4, "name": "marie curie", "born": 1867, "alive": false },
  { "id": 5, "name": "roger penrose", "born": 1931, "alive": true },
  { "id": 6, "name": "rosalind franklin", "born": 1920, "alive": true }
]

Can also be used on arrays:

const schedule = new Database([
  { department: "it", subjects: ["data structures and algorithms"] },
  { department: "physics", subjects: ["newtonian mechanics"] },
  { department: "maths", subjects: [] },
]);

schedule.$.find({
  property: { name: "subjects", preProcess: ["toLength"] },
  operator: "greaterThan",
  value: 0,
}).data;

Returns records with a non-empty subjects property:

[
  { "department": "it", "subjects": ["data structures and algorithms"] },
  { "department": "physics", "subjects": ["newtonian mechanics"] }
]
substring

Returns the part of the string between the start and end indexes, or to the end of the string:

$.find({
  property: {
    name: "name",
    preProcess: [{ fn: "substring", args: [1, 4] }],
  },
  operator: "equal",
  value: "oge",
}).data;

// => [ { id: 5, name: 'roger penrose', born: 1931, alive: true } ]

Guides and concepts

Type inference

When using the MemoryAdapter, newton will automatically infer the shape of your data based on the value passed in:

Using a single collection:

const db = new Database([
  { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" },
  { name: "Albert Einstein", born: "1879-03-14T12:00:00.000Z" },
]);

type inference using a single collection

Using multiple collections:

const db = new Database({
  scientists: [
    { name: "Isaac Newton", born: "1643-01-04T12:00:00.000Z" },
    { name: "Albert Einstein", born: "1879-03-14T12:00:00.000Z" },
  ],
  universities: [
    { name: "University of Zurich", location: "Zurich, Switzerland" },
  ],
});

type inference using a single collection

When the shape of the data can't be inferred automatically, you can pass through the shape of the data when instantiating your database:

Instantiating with the shape of your database:

const adapter = new FileAdapter("./db.json");
const db = new Database<{
  scientists: Scientist[];
  universities: University[];
}>(adapter);

License

MIT

Comments
  • JSON Schema

    JSON Schema

    Description

    Would be good to be able to accept as input a JSON schema and get a functional database.

    Features:

    • Full type hinting
    • Option to validate (would also be a good option more generally)
    enhancement 
    opened by alexberriman 1
  • Deleting an element from an array sets it to `null` instead

    Deleting an element from an array sets it to `null` instead

    When you try and delete an element from an array, rather than delete it just sets it to null

    interface Lunchbox {
      color: string;
      contents: string[];
    }
    const $db = new Database<Lunchbox[]>([
      { color: "red", contents: ["apple", "orange", "banana"] },
    ]);
    const $patch = $db.$.get({ color: "red" })
      .set({ contents: ["orange", "banana"] })
      .commit();
    console.log(JSON.stringify($db.$.get({ color: "red" }).data));
    
    // => {"color":"red","contents":[null,"orange","banana"]}
    
    bug released 
    opened by alexberriman 1
  • .upsert

    .upsert

    Have the ability to insert multiple records at a time.

    Should have at minimum the following options:

    • onDuplicate?: "skip" | "replace" | "merge"

    Where:

    • skip: new item is ignored
    • replace: old item is replaced with the new item
    • merge: new item is merged into the old item
    enhancement backlog released 
    opened by alexberriman 1
  • test(file-adapter): add tests

    test(file-adapter): add tests

    Adds tests for the file adapter. Originally thought when consuming newtondb that there was an issue causing the process to end on write, so I started writing tests to investigate. It turns out there was no issue, nodemon was simply restarting due to the database file being overwritten 🤪

    opened by alexberriman 0
  • The automated release is failing 🚨

    The automated release is failing 🚨

    :rotating_light: The automated release from the main branch failed. :rotating_light:

    I recommend you give this issue a high priority, so other packages depending on you can benefit from your bug fixes and new features again.

    You can find below the list of errors reported by semantic-release. Each one of them has to be resolved in order to automatically publish your package. I’m sure you can fix this 💪.

    Errors are usually caused by a misconfiguration or an authentication problem. With each error reported below you will find explanation and guidance to help you to resolve it.

    Once all the errors are resolved, semantic-release will release your package the next time you push a commit to the main branch. You can also manually restart the failed CI job that runs semantic-release.

    If you are not sure how to resolve this, here are some links that can help you:

    If those don’t help, or if this issue is reporting something you think isn’t right, you can always ask the humans behind semantic-release.


    Missing package.json file.

    A package.json file at the root of your project is required to release on npm.

    Please follow the npm guideline to create a valid package.json file.


    Good luck with your project ✨

    Your semantic-release bot :package::rocket:

    semantic-release 
    opened by alexberriman 0
  • Not filter

    Not filter

    Add the ability to use not as a query filter.

    e.g.

    {
      "not": {
        "property": "nationality",
        "operator": "endsWith",
        "value": "ian"
      }
    }
    
    enhancement backlog 
    opened by alexberriman 0
  • Secondary indexes

    Secondary indexes

    Currently, an index is only created for the primary key of a collection. That means for every other query where the primary key is not included, the entire collection has to be scanned. If you were able to configure multiple secondary indexes, read operations could reference the hash table as opposed to scanning the linked list.

    enhancement backlog 
    opened by alexberriman 0
  • Sort keys

    Sort keys

    Will improve performance on read operations. Records that are stored pre-sorted can be returned a lot more efficiently as opposed to sorting the entire linked list each query.

    enhancement backlog 
    opened by alexberriman 0
Releases(v0.3.2)
  • v0.3.2(Aug 24, 2022)

  • v0.3.1(Aug 2, 2022)

  • v0.3.0(Aug 2, 2022)

    0.3.0 (2022-08-02)

    Features

    • collection: add or property to conditionally execute chain operations (dc4fb79)
    View benchmarks
    • db.get (1000k records):
      • native Array.prototype.find(): 120 ops/s
      • newton without PK: 1 ops/s
      • newton with pk: 15184 ops/s
    • db.find (1000k records):
      • native Array.prototype.find(): 104 ops/s
      • newton without PK: 6 ops/s
      • newton with pk: 73685 ops/s
    • new Newton():
      • 1k records: 4763 ops/s
      • 10k records: 391 ops/s
      • 100k records: 9 ops/s
      • 1000k records: 1 ops/s
    Source code(tar.gz)
    Source code(zip)
JCS (JSON Canonicalization Scheme), JSON digests, and JSON Merkle hashes

JSON Hash This package contains the following JSON utilties for Deno: digest.ts provides cryptographic hash digests of JSON trees. It guarantee that d

Hong Minhee (洪 民憙) 13 Sep 2, 2022
Package fetcher is a bot messenger which gather npm packages by uploading either a json file (package.json) or a picture representing package.json. To continue...

package-fetcher Ce projet contient un boilerplate pour un bot messenger et l'executable Windows ngrok qui va permettre de créer un tunnel https pour c

AILI Fida Aliotti Christino 2 Mar 29, 2022
⚡ It is a simplified database module with multiple functions that you can use simultaneously with sqlite, yaml, firebase and json.

Prisma Database Developed with ?? by Roxza ⚡ An easy, open source database ?? Installation npm i prisma.db --save yarn add prisma.db ?? Importing impo

Roxza 21 Jan 3, 2023
A NodeJS Replit API package wrapped around GraphQL, returning JSON data for easy use.

repl-api.js A NodeJS Replit API package wrapped around GraphQL, returning JSON data for easy use. Contents: About Quickstart Pre-installation Installa

kokonut 5 May 20, 2022
jQuery based scrolling Bar, for PC and Smartphones (touch events). It is modern slim, easy to integrate, easy to use. Tested on Firefox/Chrome/Maxthon/iPhone/Android. Very light <7ko min.js and <1Ko min.css.

Nice-Scrollbar Responsive jQuery based scrolling Bar, for PC and Smartphones (touch events). It is modern slim, easy to integrate, easy to use. Tested

Renan LAVAREC 2 Jan 18, 2022
Interplanetary Database: A Database built on top of IPFS and made immutable using Ethereum blockchain.

IPDB IPDB (Interplanetary Database) is a key/value store database built on top of IPFS (Interplanetary File System). Project is intended to be an MVP

turinglabs 8 Oct 6, 2022
Visualize, modify, and build your database with dbSpy! An open-source data modeling tool to facilitate relational database development.

Visualize, modify, and build your database with dbSpy! dbSpy is an open-source data modeling tool to facilitate relational database development. Key F

OSLabs 115 Dec 22, 2022
Deploying Fake Back-End Server & DataBase Using JSON-SERVER, GitHub, and Heroku

Deploying Fake Back-End Server & DataBase Using JSON-SERVER, GitHub, and Heroku. In this article, we will create and host a fake server that we can de

Israel David 0 Sep 5, 2022
An easy to implement marquee JQuery plugin with pause on hover support. I know its easy because even I can use it.

Simple-Marquee Copyright (C) 2016 Fabian Valle An easy to implement marquee plugin. I know its easy because even I can use it. Forked from: https://gi

null 16 Aug 29, 2022
Pretty-print-json - 🦋 Pretty-print JSON data into HTML to indent and colorize (written in TypeScript)

pretty-print-json Pretty-print JSON data into HTML to indent and colorize (written in TypeScript) 1) Try It Out Interactive online tool to format JSON

Center Key 87 Dec 30, 2022
JSON Struct is a vocabulary that allows you to annotate and validate JSON documents.

JSON-Struct JSON Struct is a vocabulary that allows you to annotate and validate JSON documents. Examples Basic This is a simple example of vocabulary

Saman 3 May 8, 2022
✏️ A small jQuery extension to turn a static HTML table into an editable one. For quickly populating a small table with JSON data, letting the user modify it with validation, and then getting JSON data back out.

jquery-editable-table A small jQuery extension to turn an HTML table editable for fast data entry and validation Demo ?? https://jsfiddle.net/torrobin

Tor 7 Jul 31, 2022
Json-parser - A parser for json-objects without dependencies

Json Parser This is a experimental tool that I create for educational purposes, it's based in the jq works With this tool you can parse json-like stri

Gabriel Guerra 1 Jan 3, 2022
JSON Visio is data visualization tool for your json data which seamlessly illustrates your data on graphs without having to restructure anything, paste directly or import file.

JSON Visio is data visualization tool for your json data which seamlessly illustrates your data on graphs without having to restructure anything, paste directly or import file.

Aykut Saraç 20.6k Jan 4, 2023
Prisma 2+ generator to emit a JSON file that can be run with json-server

Prisma JSON Server Generator A Prisma generator that automates creating a JSON file that can be run as a server from your Prisma schema. Explore the o

Omar Dulaimi 14 Jan 7, 2023
Types generator will help user to create TS types from JSON. Just paste your single object JSON the Types generator will auto-generate the interfaces for you. You can give a name for the root object

Types generator Types generator is a utility tool that will help User to create TS Interfaces from JSON. All you have to do is paste your single objec

Vineeth.TR 16 Dec 6, 2022
Add aliasing support to Vite from tsconfig.json or jsconfig.json files

Config to Alias Config to Alias adds aliasing support to Astro, JavaScript, TypeScript, and CSS files. Usage Install Config to Alias. npm install @ast

Astro Community 4 Mar 17, 2023
Easy server-side and client-side validation for FormData, URLSearchParams and JSON data in your Fresh app 🍋

Fresh Validation ??     Easily validate FormData, URLSearchParams and JSON data in your Fresh app server-side or client-side! Validation Fresh Validat

Steven Yung 20 Dec 23, 2022
Tracing the easy way using JSON.

MikroTrace Tracing the easy way using JSON. JSON tracer that tries to emulate OpenTelemetry semantics and behavior. Built as a ligher-weight way to ha

Mikael Vesavuori 11 Nov 14, 2022