Linked QL

âď¸ Save the overhead working with SQL and structured data - from the time and effort spent figuring out relational queries to the labour managing schemas! Try a modern, simplistic take on SQL and databases in general!
Linked QL is a DB query client that simplfies how you interact with your database and manage your schemas.
đĽ Takes the ORM and friends out of the way and let's you just write SQL, but SQL that you will actually enjoy. (Linked QL extends standard SQL with new syntax sugars that let you write relational queries in less than half the code and without a single JOIN clause.)
âĄď¸ Takes the process out of schema management and lets you just ALTER away your DB, but in a safety net. (Linked QL extends your DB behind the scenes to automatically version each edit you make and have them kept as "savepoints" that you can always rollback to.)
đĽ Brings the "schema-as-code" paradigm to its true meaning and essentially lets you have your entire DB structure go in a single schema.json
file that you edit in-place, as against the "hundreds of migration files" story. (Linked QL essentially rewrites that story.)
It comes as a small library and is usable over your DB of choice - from the server-side Postgres and MySQL, to the client-side IndexedDB, to the plain JSON object!
Jump to sections and features:
Getting Started
Install Linked QL:
npm install @linked-db/linked-ql
Obtain the Linked QL client for your target database:
-
For SQL databases, install the regular SQL client you use for your DB. (Typically, pg
for Postgres, mysql2
for MySQL databases.)
Given a Postgres DB, install the pg
client:
npm install pg
Use Linked QL as a wrapper over that:
import pg from 'pg';
import LinkedQl from '@linked-db/linked-ql/sql';
const pgClient = new pg.Client({
host: 'localhost',
port: 5432,
});
await pgClient.connect();
const client = new LinkedQl(pgClient, { dialect: 'postgres' });
-
For the client-side IndexedDB, import and instantiate the IDB client. (Coming soon)
import LinkedQl from '@linked-db/linked-ql/idb';
const client = new LinkedQl;
-
To work with Linked QL's in-memory object database, import and instantiate the ODB client. (Coming soon)
import LinkedQl from '@linked-db/linked-ql/odb';
const LinkedQlClient = new LinkedQl;
All client
instances above implement the same interface:
client.query('SELECT fname, lname FROM users WHERE role = $1', { values: ['admin'] }).then(result => {
console.log(result);
});
const result = await client.query('SELECT fname, lname FROM users WHERE role = $1', { values: ['admin'] });
console.log(result);
Other APIs are covered just ahead in the API section.
Introducing Magic Paths
đĽ Express relationships graphically.
JOINS can be good but can be a mess as they almost always obfuscate your entire query! But what if you didn't have to write JOINS to express certain relationships?
Meet Linked QL's magic path operators, a syntax extension to SQL, that lets you connect to columns on other tables without writing a single JOIN. Linked QL uses heuristics on your DB structure to figure out the details and the relevant JOINs behind the scenes.
Where you normally would write...
SELECT title, users.fname AS author_name FROM posts
LEFT JOIN users ON users.id = posts.author
Linked QL lets you draw a path to express the relationship:
SELECT title, author ~> fname AS author_name FROM posts
And here's a scenario showing a typical schema and an example query each:
CREATE TABLE users (
id int primary key generated always as identity,
title varchar,
name varchar,
role int references roles (id),
created_time timestamp
);
CREATE TABLE books (
id int primary key generated always as identity,
title varchar,
content varchar,
author int references users (id),
created_time timestamp
);
SELECT book.id, book.title, content, book.created_time, user.id AS author_id, user.title AS author_title, user.name AS author_name
FROM books AS book LEFT JOIN users AS user ON user.id = book.author
SELECT id, title, content, created_time, author ~> id, author ~> title, author ~> name
FROM books
⨠PRO: About 50% code, and whole namespacing exercise, now eliminated; all with zero upfront setup!
Taking that further, paths can be multi-level:
SELECT * FROM books
WHERE author ~> role ~> codename = 'admin'
and they can also be used to express incoming references:
SELECT * FROM users
WHERE author <~ books ~> title = 'Beauty and the Beast'
Introducing Auto-Versioning
âĄď¸ Create, Alter, and Drop schemas without needing to worry about versioning.
Databases have historically lacked the concept of versioning, and that has seen all of the engineering work pushed down to the client application. If you've ever had to adopt a special process for defining and managing your schemas, wherein changes are handled through serially-named files within your application, each written as an UP
/DOWN
pair of actions, and in all supported by tooling...
app
ââ migrations
â â
â ââ 20240523_1759_create_users_table_and_drop_accounts_table
â â â
â â ââ up.sql
â â â CREATE TABLE users (id INT, first_n...);
â â â DROP TABLE accounts;
â â ââ down.sql
â â DROP TABLE users;
â â CREATE TABLE accounts (id INT, first_name VAR...);
â â
â ââ 20240523_1760_add_last_login_to_users_table_and_rename_order_status_table
â â â
â â ââ up.sql
â â â ALTER TABLE users ADD COLUMN last_lo...;
â â â ALTER TABLE order_status RENAME TO o...;
â â ââ down.sql
â â ALTER TABLE users DROP COLUMN last_login;
â â ALTER TABLE order_tracking RENAME TO order_status;
â â
â ââ +256 more...
then you've faced the problem that this defeciency in databases creates! But what if databases magically got to do the heavy lifting?
Meet Linked QL's little addition to your database that does exactly that and lets you just alter your DB however you may but in the safety net of some behind-the-scenes magic that snapshots your schema before each alteration! Meet Automatic Schema Savepoints and Rollbacks!
Linked QL:
const savepoint = await client.query('CREATE TABLE public.users (id int, name varchar)', {
description: 'Create users table',
});
console.log(savepoint.description);
console.log(savepoint.versionTag);
console.log(savepoint.savepointDate);
(More details in the Savepoint API.)
⨠PRO: Whole engineering work now essentially moved over to the DB where it rightly belongs; all with zero upfront setup!
Taking that further, you get a nifty rollback button should you want to:
await savepoint.rollback();
and you can go many levels back:
let savepoint;
while(savepoint = await client.database('public').savepoint()) {
await savepoint.rollback();
if (savepoint.versionTag === 3) break;
}
and you can "undo" a rollback, or in other words, roll forward to a point in time:
let savepoint = await client.database('public').savepoint({ direction: 'forward' });
await savepoint.rollback();
You essentially are able to go back in time or forward in time as randomly as iteration may demand.
Re-Introducing Schema-as-Code with schema.json
đĽ Have your entire DB structure live in a single schema.json
file that you edit in-place!
With schema versioning now over to the database, much of the old conventions and formalities should now be irrelevant. We found that we could essentially streamline the whole "database" footprint from spanning hundreds of migration files to fitting into a single schema.json
(or schema.yml
) file!
schema.json
[
{
"name": "database_1",
"tables": []
},
{
"name": "database_2",
"tables": []
}
]
Explore the structure
An example table object:
{
"name": "users",
"columns": [],
"constraints": [],
"indexes": []
}
An example column object:
{
"name": "id",
"type": "int",
"primaryKey": true,
"identity": true
}
More column examples
{
"name": "full_name",
"type": ["varchar","101"],
"generated": "(first_name || ' ' || last_name)"
}
{
"name": "email",
"type": ["varchar","50"],
"uniqueKey": true,
"notNull": true,
"check": "(email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')"
}
{
"name": "parent",
"type": "int",
"notNull": true,
"references": {
"targetTable": "users",
"targetColumns": ["id"],
"matchRull": "full",
"updateRule": "cascade",
"deleteRule": "restrict"
}
}
An example constraint object:
{
"type": "PRIMARY_KEY",
"columns": ["id"],
"name": "constraint_name"
}
More constraint examples
{
"type": "UNIQUE_KEY",
"columns": ["email"]
}
{
"type": "FOREIGN_KEY",
"columns": ["parent"],
"targetTable": "users",
"targetColumns": ["id"],
"matchRull": "full",
"updateRule": "cascade",
"deleteRule": "restrict"
}
{
"type": "CHECK",
"expr": "(email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')"
}
An example index object:
{
"type": "FULLTEXT",
"columns": ["full_name"]
}
More index examples
{
"type": "SPATIAL",
"columns": ["full_name"]
}
Now, if you had that somewhere in your application, say at ./database/schema.json
, Linked QL could help keep it in sync both ways with your database:
- you add or remove a database or table or column... and it is automatically reflected in your DB structure at the click of a command:
linkedql migrate
- your colleague makes new changes from their codebase... and it is automatically reflected in your local copy at your next
git pull
, or at the click of a command: linkedql refresh
đĽ You also get to see a version indicator on each database object in your schema essentially incrementing on each migrate operation (whether by you or by colleague), and decrementing on each rollback operation (whether by you or by colleague).
Thanks to a DB-native schema version control system, no need to maintain past states, or risk losing them; the DB now becomes the absolute source of truth for both itself and its client applications, as against the other way around. (You may want to see how that brings us to true "Schema as Code" in practice.)
To setup:
-
Make a directory within your application for database concerns. Linked QL will look in ./database
, but you will be able to point to your preferred location when running Linked QL commands.
-
Have a driver.js
file in that directory that has a default export function that returns a Linked QL instance. This will be imported and used by Linked QL to interact with your database. This could look something like:
import pg from 'pg';
import SQLClient from '@linked-db/linked-ql/sql';
const pgClient = new pg.Client({
host: 'localhost',
port: 5432,
});
await pgClient.connect();
const sqlClient = new SQLClient(pgClient, { dialect: 'postgres' });
export default function() {
return sqlClient;
}
-
Have your DB structure defined in a schema.json
file in that directory. (See schema.json
above for a guide.)
You can always extend your schema with new objects, and you can always drop objects or edit them in-place. For an existing database, table, column, constraint, or index, names may be changed, but not in-place! A "rename" operation is done with the addition of a temporary $name
attribute:
{
"name": "old_name",
"$name": "new_name"
}
The old name being in place is needed to find the target during migration. The temporary $name
attribute automatically disappears after new name has been picked up by Linked QL at next linkedql migrate
.
To run:
- Use
linkedql migrate
to walk through your staged local changes and interactively perform a migration on your database. - Use
linkedql rollback
to walk through the latest savepoint at each database and interactively perform a rollback. - Use
linkedql leaderboard
to just view the latest savepoint at each database.
(More details in the Linked QL CLI section.)
Linked QL API
Here's for a quick overview of the Linked QL API:
Here we talk about the client.query()
method in more detail along with other Linked QL APIs that essentially let us do the same things possible with client.query()
, but this time, programmatically.
As an example of one of these APIs, a CREATE DATABASE
operation...
const savepoint = await client.query('CREATE DATABASE IF NOT EXISTS database_1');
could be programmatically achieved as:
const savepoint = await client.createDatabase('database_1', { ifNotExists: true });
That said, while the createDatabase()
method is associated with the base Client
object, the different programmatic query APIs in Linked QL are actually organized into three hierarchical scopes:
-
the top-level scope (represented by the Client
interface), featuring methods such as: createDatabase()
, alterDatabase()
, dropDatabase()
, hasDatabase()
, describeDatabase()
-
the database-level scope (represented by a certain Database
interface), featuring methods such as: createTable()
, alterTable()
, dropTable()
, hasTable()
, describeTable()
-
the table-level scope (represented by a certain Table
interface), featuring methods such as: select()
, insert()
, upsert()
, update()
, delete()
Each object provides a way to narrow in to the next; e.g. from the top-level scope to a database scope...
const database_1 = client.database('database_1');
and from there to a table scope:
const table_1 = database.table('table_1');
These APIs at play would look something like:
await client.createDatabase('database_1', { ifNotExists: true });
await client.database('database_1').createTable({
name: 'table_1', columns: [
{ name: 'column_1', type: 'int', identity: true, primaryKey: true },
{ name: 'column_2', type: 'varchar' },
{ name: 'column_3', type: 'varchar' },
]
});
await client.database('database_1').table('table_1').insert({
column_2: 'Column 2 test content',
column_3: 'Column 3 test content',
});
These APIs and more are what's covered in this section.
Click on a definition to expand.
The Client
API
Client is the top-level object for the individual database kinds in Linked QL. Each instance implements the following interface:
See content
client.query()
:
Run any SQL query.
client.query(sql: string, options?: Options): Promise<Savepoint | Array<object>>
âď¸ Spec:
sql
(string): an SQL query.options
(Options, optional): extra parameters for the query.- Return value: a
Savepoint
instance when it's a CREATE
, ALTER
, or DROP
operation, an array (the result set) when it's a SELECT
query or when it's an INSERT
, UPDATE
, or DELETE
operation that has a RETURNING
clause, a number, in all other cases, indicating number of rows processed by the query.
â˝ď¸ Usage:
Run a CREATE
, ALTER
, or DROP
operation and get back a reference to the savepoint associated with it:
const savepoint = await client.query('ALTER TABLE users RENAME TO accounts');
console.log(savepoint.versionTag);
await savepoint.rollback();
or a SELECT query, and get back a result set:
const rows = await client.query('SELECT * FROM users WHERE id = 4');
console.log(rows.length);
or an INSERT
, UPDATE
, or DELETE
operation with a RETURNING
clause, and ge back a result set:
const rows = await client.query('INSERT INTO users SET name = \'John Doe\' RETURNING id');
console.log(rows.length);
or an INSERT
, UPDATE
, or DELETE
operation without a RETURNING
clause, and ge back a number indicating the number of rows processed by the query:
const rowCount = await client.query('INSERT INTO users SET name = \'John Doe\'');
console.log(rowCount);
Some additional parameters via options
:
-
dialect
(string, optional): the SQL dialect in use: postgres
(the default) or mysql
. (Details soon as to how this is treated by Linked QL.)
const rows = await client.query('ALTER TABLE users MODIFY COLUMN id int', { dialect: 'mysql' });
-
values
((string | number | boolean | null | Date | object | any[])[], optional): the values for parameters in the query.
const rows = await client.query('SELECT * FROM users WHERE id = $1', { values: [4] });
-
description
(string, optional): the description for a CREATE
, ALTER
, DROP
operation and for the underlying savepoint they create.
const savepoint = await client.query('DROP DATABASE test', { description: 'No longer needed' });
-
noCreateSavepoint
(boolean, optional): a flag to disable savepoint creation on a CREATE
, ALTER
, DROP
operation.
await client.query('DROP DATABASE test', { noCreateSavepoint: true });
client.createDatabase()
:
Dynamically run a CREATE DATABASE
operation.
client.createDatabase(createSpec: string | { name: string, tables?: Array }, options?: Options): Promise<Savepoint>
âď¸ Spec:
createSpec
(string | { name: string, tables?: Array }): the database name, or an object corresponding to the database object in schema.json.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
Specify database by name:
const savepoint = await client.createDatabase('database_1', { description: 'Just testing database creation' });
or by a schema object, with an optional list of tables to be created along with it. (Each listed table corresponding to the table object in schema.json.):
const savepoint = await client.createDatabase({
name: 'database_1',
tables: [{
name: 'table_1'
columns: [{ name: 'column_1', type: 'int' }, { name: 'column_2', type: 'time' }]
}]
}, { description: 'Just testing database creation' });
Some additional parameters via options
:
-
ifNotExists
(boolean, optional): a flag to conditionally create the database.
const savepoint = await client.createDatabase('database_1', { ifNotExists: true, description: 'Just testing database creation' });
client.alterDatabase()
:
Dynamically run an ALTER DATABASE
operation.
client.alterDatabase(alterSpec: string | { name: string, tables?: string[] }, callback: (schema: DatabaseSchema) => void, options?: Options): Promise<Savepoint>
âď¸ Spec:
alterSpec
(string | { name: string, tables?: string[] }): the database name, or an object with the name and, optionally, a list of tables to be altered along with it.callback
((schema: DatabaseSchema) => void): a function that is called with the requested schema. This can be async. Received object is a DatabaseSchema
instance.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
Specify database by name:
const savepoint = await client.alterDatabase('database_1', schema => {
schema.name('database_1_new');
}, { description: 'Renaming for testing purposes' });
or by an object, with an optional list of tables to be altered along with it:
const savepoint = await client.alterDatabase({ name: 'database_1', tables: ['table_1'] }, schema => {
schema.name('database_1_new');
schema.table('table_1').column('column_1').name('column_1_new');
schema.table('table_1').column('column_2').type('varchar');
}, { description: 'Renaming for testing purposes' });
client.dropDatabase()
:
Dynamically run a DROP DATABASE
operation.
client.dropDatabase(dbName: string, options?: Options): Promise<Savepoint>
âď¸ Spec:
dbName
(string): the database name.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
const savepoint = await client.dropDatabase('database_1', { description: 'Dropping for testing purposes' });
Some additional parameters via options
:
-
ifExists
(boolean, optional): a flag to conditionally drop the database.
const savepoint = await client.dropDatabase('database_1', { ifExists: true, description: 'Dropping for testing purposes' });
-
cascade
(boolean, optional): a flag to force-drop the database along with its dependents.
const savepoint = await client.dropDatabase('database_1', { cascade: true, description: 'Dropping for testing purposes' });
client.hasDatabase()
:
Check if a database exists.
client.hasDatabase(dbName: string): Promise<Boolean>
âď¸ Spec:
dbName
(string): the database name.- Return value: Boolean.
â˝ď¸ Usage:
const exists = await client.hasDatabase('database_1');
client.describeDatabase()
:
Get the schema structure for a database.
client.describeDatabase(dbName: string): Promise<{ name: string, tables: Array }>
âď¸ Spec:
dbName
(string): the database name.- Return value: an object corresponding to the database object in schema.json.
â˝ď¸ Usage:
const schema = await client.describeDatabase('database_1');
console.log(schema.name);
console.log(schema.tables);
client.databases()
:
Get a list of available databases.
client.databases(): Promise<Array<string>>
âď¸ Spec:
- Return value: an array of database names.
â˝ď¸ Usage:
const databases = await client.databases();
console.log(databases);
client.database()
:
Obtain a Database
instance.
client.database(dbName: string): Database
âď¸ Spec:
dbName
(string): the database name.- Return value: a
Database
instance.
â˝ď¸ Usage:
const database = client.database('database_1');
The Database
API
Database is the API for database-level operations. This object is obtained via client.database()
See content
database.name
:
The name associated with the Database instance.
database.name: (string, readonly)
â˝ď¸ Usage:
const database = client.database('test_db');
console.log(database.name);
database.createTable()
:
Dynamically run a CREATE TABLE
operation.
database.createTable(createSpec: { name: string, columns: Array, constraints?: Array, indexes?: Array }, options?: Options): Promise<Savepoint>
âď¸ Spec:
createSpec
({ name: string, columns: Array, constraints?: Array, indexes?: Array }): an object corresponding to the table object in schema.json.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
const savepoint = await database.createTable({
name: 'table_1'
columns: [
{ name: 'column_1', type: 'int' },
{ name: 'column_2', type: 'time' }
]
}, { description: 'Just testing table creation' });
Some additional parameters via options
:
-
ifNotExists
(boolean, optional): a flag to conditionally create the table.
const savepoint = await database.createTable({
name: 'table_1'
columns: [ ... ]
}, { ifNotExists: true, description: 'Just testing table creation' });
database.alterTable()
:
Dynamically run an ALTER TABLE
operation.
database.alterTable(tblName: string, callback: (schema: TableSchema) => void, options?: Options): Promise<Savepoint>
âď¸ Spec:
tblName
(string): the table name.callback
((schema: TableSchema) => void): a function that is called with the requested table schema. This can be async. Received object is a TableSchema
instance.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
const savepoint = await database.alterTable('table_1', schema => {
schema.name('table_1_new');
schema.column('column_1').type('int');
schema.column('column_2').drop();
}, { description: 'Renaming for testing purposes' });
database.dropTable()
:
Dynamically run a DROP TABLE
operation.
database.dropTable(tblName: string, options?: Options): Promise<Savepoint>
âď¸ Spec:
tblName
(string): the table name.options
(Options, optional): as described in query()
.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
const savepoint = await database.dropTable('table_1', { description: 'Dropping for testing purposes' });
Some additional parameters via options
:
-
ifExists
(boolean, optional): a flag to conditionally drop the table.
const savepoint = await database.dropTable('table_1', { ifExists: true, description: 'Dropping for testing purposes' });
-
cascade
(boolean, optional): a flag to force-drop the table along with its dependents.
const savepoint = await database.dropTable('table_1', { cascade: true, description: 'Dropping for testing purposes' });
database.hasTable()
:
Check if a table exists.
database.hasTable(tblName: string): Promise<Boolean>
âď¸ Spec:
tblName
(string): the table name.- Return value: Boolean.
â˝ď¸ Usage:
const exists = await database.hasTable('table_1');
database.describeTable()
:
Get the schema structure for a table.
database.describeTable(tblName: string): Promise<{ name: string, columns: Array, constraints: Array, indexes: Array }>
âď¸ Spec:
tblName
(string): the table name.- Return value: an object corresponding to the table object in schema.json.
â˝ď¸ Usage:
const schema = await database.describeTable('table_1');
console.log(schema.name);
console.log(schema.columns);
database.tables()
:
Get a list of available tables.
database.tables(): Promise<Array<string>>
âď¸ Spec:
- Return value: an array of table names.
â˝ď¸ Usage:
const tables = await database.tables();
console.log(tables);
database.table()
:
Obtain a Table
instance.
database.table(tblName: string): Table
âď¸ Spec:
tblName
(string): the table name.- Return value: a
Table
instance.
â˝ď¸ Usage:
const table = database.table('table_1');
database.savepoint()
:
Obtain the next available savepoint for given database.
database.savepoint(options?: { direction: string }): Savepoint
âď¸ Spec:
options
({ direction: string }, optional): extra paramters for the method.- Return value: a
Savepoint
instance.
â˝ď¸ Usage:
const savepoint = await database.savepoint();
console.log(savepoint.versionTag);
await savepoint.rollback();
Some additional parameters via options
:
-
direction
(string, optional): the direction of lookup - either back in time: backward
(the default), or forward in time: forward
.
const savepoint = await database.savepoint({ direction: 'forward' });
console.log(savepoint.versionTag);
await savepoint.rollback();
The Table
API
Table is the API for table-level operations. This object is obtained via database.table()
See content
table.name
:
The name associated with the Table instance.
table.name: (string, readonly)
â˝ď¸ Usage:
const table = client.database('test_db').table('table_1');
console.log(table.name);
table.count()
:
Count total entries in table.
table.count(expr?: string | Function = *): Promise<number>
âď¸ Spec:
expr
(string | Function = *, optional): a string denoting column name, or a function that recieves a Field object with which to build an expression. Defaults to *
.- Return value: number.
â˝ď¸ Usage:
const rowCount = await table.count();
const rowCount = await table.count('column_1');
table.select()
:
Dynamically run a SELECT
query.
table.select(fields?: (string | Function)[] = *, where?: number | object | Function | true): Promise<Array<object>>
table.select(where?: number | object | Function): Promise<Array<object>>
âď¸ Spec:
fields
((string | Function)[] = *, optional): an array of fields to select. (A field being either a column name string, or a function that recieves a Field object with which to build an expression.)where
(number | object | Function | true, optional): a number targeting the primary key value of the target row, or an object specifying some column name/column value conditions, or a function that recieves an Assertion object with which to build the conditions, or the value true
denoting all records. Defaults to true
.- Return value: an array (the result set).
â˝ď¸ Usage:
const result = await table.select();
const result = await table.select(['first_name', 'last_name', 'email'], 4);
const result = await table.select(4);
const result = await table.select({ first_name: 'John', last_name: 'Doe' });
table.insert()
:
Dynamically run an INSERT
operation. (With automatic parameter binding.)
table.insert(payload: object | object[], returnList?: (string | Function)[]): Promise<Array<object> | number>
table.insert(columns: string[], values: any[][], returnList?: (string | Function)[]): Promise<Array<object> | number>
âď¸ Spec:
payload
(object | object[]): an object denoting a single entry, or an array of said objects denoting multiple entries. (An entry having the general form: { [key: string]: string | number | boolean | null | Date | object | any[] }
where arrays and objects as values are automatically JSON-stringified.)columns
(string[]): just column names (as against the key/value payload
in the first call pattern).values
(any[][]): a two-dimensional array of just values (as against the key/value payload
in the first call pattern), denoting multiple entries.returnList
(((string | Function)[] | false), optional): a list of fields, corresponding to a select list, specifying data to be returned from the just inserted row. (Equivalent to Postgres' RETURNING clause, but supported for other DB kinds in Linked QL.)- Return value: a number indicating number of rows processed by the query, or where
returnList
was provided, an array of the processed row(s).
â˝ď¸ Usage:
await table.insert({ first_name: 'John', last_name: 'Doe', email: 'johndoe@example.com'});
await table.insert([
{ first_name: 'John', last_name: 'Doe', email: 'johndoe@example.com'},
{ first_name: 'James', last_name: 'Clerk', email: 'jamesclerk@example.com'},
]);
await table.insert(['first_name', 'last_name', 'email'], [
['John', 'Doe', 'johndoe@example.com'],
['James', 'Clerk', 'jamesclerk@example.com'],
]);
const insertedRows = await table.insert({ first_name: 'John', last_name: 'Doe', email: 'johndoe@example.com'}, ['id']);
table.upsert()
:
Dynamically run an UPSERT
operation. (With automatic parameter binding.)
table.upsert(payload: object | object[], returnList?: (string | Function)[]): Promise<Array<object> | number>
table.upsert(columns: string[], values: any[][], returnList?: (string | Function)[]): Promise<Array<object> | number>
âď¸ Spec:
payload
(object | object[]): as described in insert()
.columns
(string[]): as described in insert()
.values
(any[][]): as described in insert()
.returnList
((string | Function)[], optional): as described in insert()
.- Return value: as described in
insert()
.
â˝ď¸ Usage:
An UPSERT
operation is an INSERT
operation that automatically converts to an UPDATE
operation where given record already exists. API usage is same as insert()
but as upsert()
.
table.update()
:
Dynamically run an UPDATE
operation. (With automatic parameter binding.)
table.update(where: number | object | Function | true, payload: object, returnList?: (string | Function)[]): Promise<Array<object> | number>
âď¸ Spec:
where
(number | object | Function | true): as described in select()
.payload
(object): an object having the general form: { [key: string]: string | number | boolean | null | Date | object | any[] }
where arrays and objects as values are automatically JSON-stringified.returnList
((string | Function)[], optional): as described in insert()
.- Return value: as described in
insert()
.
â˝ď¸ Usage:
await table.update(4, { first_name: 'John', last_name: 'Doe' });
const updatedRows = await table.update({ email: 'johndoe@example.com' }, { first_name: 'John', last_name: 'Doe' }, ['*']);
await table.update(true, { updated_at: new Date });
table.delete()
:
Dynamically run a DELETE
operation. (With automatic parameter binding.)
table.delete(where: number | object | Function | true, returnList?: (string | Function)[]): Promise<Array<object> | number>
âď¸ Spec:
where
(number | object | Function | true): as described in select()
.returnList
((string | Function)[], optional): as described in insert()
.- Return value: as described in
insert()
.
â˝ď¸ Usage:
await table.delete(4);
const deletedRow = await table.delete({ email: 'johndoe@example.com' });
await table.delete(true);
The Savepoint
API
Savepoint is an object representation of a database's savepoint. This object is obtained either via database.savepoint()
or via a CREATE
, ALTER
, or DROP
operation.
See content
savepoint.id
:
The UUID associated with the savepoint.
savepoint.id: (UUID, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.id);
savepoint.databaseTag
:
The subject database's generic identifier that transcends name changes.
savepoint.databaseTag: (string, readonly)
â˝ď¸ Usage:
Consider a database's generic identifier before and after a name change:
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.databaseTag);
await client.alterDatabase('test_db', schema => schema.name('test_db_new'));
const savepoint = await client.database('test_db_new').savepoint();
console.log(savepoint.databaseTag);
savepoint.versionTag
:
The savepoint's version tag.
savepoint.versionTag: (number, readonly)
â˝ď¸ Usage:
const savepoint = await client.createDatabase({
name: 'test_db',
tables: [{
name: 'test_tbl1',
columns: [],
}]
});
console.log(savepoint.versionTag);
const savepoint = await client.database('test_db').createTable({
name: 'test_tbl2',
columns: [],
});
console.log(savepoint.versionTag);
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.versionTag);
savepoint.versionMax
:
The database's peak version regardless of its current rollback level.
savepoint.versionMax: (number, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.versionTag);
console.log(savepoint.versionMax);
await savepoint.rollback();
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.versionTag);
console.log(savepoint.versionMax);
savepoint.cursor
:
The savepoint's current level in the database's list of available savepoints.
savepoint.cursor: (string, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.cursor);
savepoint.description
:
The description for the changes associated with the savepoint.
savepoint.description: (string, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').createTable({
name: 'test_tbl2',
columns: [],
}, { description: 'Create test_tbl2' });
console.log(savepoint.description);
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.description);
savepoint.savepointDate
:
The savepoint's creation date.
savepoint.savepointDate: (Date, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').savepoint();
console.log(savepoint.savepointDate);
savepoint.rollbackDate
:
The savepoint's rollback date.
savepoint.rollbackDate: (Date, readonly)
â˝ď¸ Usage:
const savepoint = await client.database('test_db').createTable({
name: 'test_tbl2',
columns: [],
}, { description: 'Create test_tbl2' });
console.log(savepoint.rollbackDate);
await savepoint.rollback();
console.log(savepoint.rollbackDate);
const savepoint = await client.database('test_db').savepoint({ direction: 'forward' });
console.log(savepoint.rollbackDate);
savepoint.rollbackEffect
:
A single-word summary of the effect that rolling back to this savepoint will have on subject DB.
savepoint.rollbackEffect: (string, readonly)
â˝ď¸ Usage:
Will rolling back to given savepoint mean dropping or re-creating the subject database?:
For a create operation...
const savepoint = await client.createDatabase('test_db', { descripton: 'Create db' });
Rolling back will mean dropping the DB:
console.log(savepoint.descripton);
console.log(savepoint.rollbackEffect);
await savepoint.rollback();
console.log(savepoint.rollbackEffect);
Having rolled back, rolling forward will mean a re-creation of the DB:
const savepoint = await client.database('test_db').savepoint({ direction: 'forward' });
console.log(savepoint.descripton);
console.log(savepoint.rollbackEffect);
Compare with that of rolling back table-level operations - which always just has an ALTER
effect:
const savepoint = await client.database('test_db').createTable({
name: 'test_tbl2',
columns: [],
}, { description: 'Create test_tbl2' });
console.log(savepoint.descripton);
console.log(savepoint.rollbackEffect);
await savepoint.rollback();
console.log(savepoint.rollbackEffect);
const savepoint = await client.database('test_db').savepoint({ direction: 'forward' });
console.log(savepoint.descripton);
console.log(savepoint.rollbackEffect);
savepoint.rollbackQuery
:
A query preview of the rollback.
savepoint.rollbackQuery: ({ toString(): string }, readonly)
â˝ď¸ Usage:
You get a query instance that is toString()able:
For a create operation...
const savepoint = await client.createDatabase('test_db', { descripton: 'Create db' });
Rolling back will mean dropping the DB:
console.log(savepoint.rollbackQuery.toString());
savepoint.isNextPointInTime()
:
Check if the savepoint is the next actual point in time for the database.
savepoint.isNextPointInTime(): Promise<boolean>
âď¸ Spec:
â˝ď¸ Usage:
For a new operation, that would be true:
const dbCreationSavepoint = await client.createDatabase('test_db');
console.log(await dbCreationSavepoint.isNextPointInTime());
But after having performed more operations, that wouldn't be:
const tblCreationSavepoint = await client.database('test_db').createTable({
name: 'test_tbl',
columns: [{
name: 'id',
type: 'int'
}]
});
console.log(await tblCreationSavepoint.isNextPointInTime());
console.log(await dbCreationSavepoint.isNextPointInTime());
Rollback table creation and test dbCreationSavepoint
's position again:
await tblCreationSavepoint.rollback();
console.log(await tblCreationSavepoint.isNextPointInTime());
console.log(await dbCreationSavepoint.isNextPointInTime());
savepoint.rollback()
:
Rollback all changes associated with given savepoint.
savepoint.rollback(): Promise<boolean>
âď¸ Spec:
â˝ď¸ Usage:
Create database and rollback:
const savepoint = await client.createDatabase('test_db', { descripton: 'Create db' });
await savepoint.rollback();
Undo the rollback; i.e. roll forward:
const savepoint = await client.database('test_db').savepoint({ direction: 'forward' });
await savepoint.rollback();
savepoint.toJson()
:
Get a plain object representation of the savepoint.
savepoint.toJson(): object
âď¸ Spec:
- Return value: an object of the form
{ id: string, name: string, databaseTag: string, versionTag: number, versionMax: number, cursor: string, description: string, savepointDate: Date, rollbackDate: Date | null }
.
â˝ď¸ Usage:
const savepoint = await client.createDatabase('test_db', { descripton: 'Create db' });
console.log(savepoint.toJson());
savepoint.schema()
:
Get the subject DB's schema snapshot at this point in time.
savepoint.schema(): object
âď¸ Spec:
- Return value: an object corresponding to the database object in schema.json.
â˝ď¸ Usage:
const savepoint = await client.database('test_db').createTable({
name: 'test_tbl',
columns: [{
name: 'id',
type: 'int'
}]
});
console.log(savepoint.schema());
const savepoint = await client.database('test_db').savepoint();
await savepoint.schema();
savepoint.name()
:
Get the subject database's name.
savepoint.name(postRollback?: boolean): string
âď¸ Spec:
postRollback
(boolean, optional): in case a name change was captured in the savepoint, whether to return the database's post-rollback name. Otherwise the database's active, pre-rollback name is returned.- Return value: the database name.
â˝ď¸ Usage:
const savepoint = await client.alterDatabase('test_db', schema => schema.name('test_db_new'));
console.log(savepoint.name());
console.log(savepoint.name(true));
Linked QL CLI
Linked QL migrations are a small addition to Linked QL. And it comes ready-to-use, via the linkedql
command, upon Linked QL's installation. (No extra setup is required.)
Overview
The linkedql
command comes as part of your local Linked QL installation and not as a global package, and that means you'll need the npx
prefix to run the commands below. E.g.
npx linkedql migrate
In each case, you can use the --dir
flag to point Linked QL to your "database" directory:
npx linkedql migrate --dir="./src/database-stuff"
(Relative paths will resolve against your current working directory (CWD).)
Use the --db
flag to run given command for a specific database out of the list of databases:
npx linkedql migrate --db=database_1
Use the flag --auto
to turn off prompts and just take the "sensible-default" action:
npx linkedql migrate --auto
Commands
linkedql migrate
Interactively run new migrations. Linked QL looks through your local schema and compares with your active DB structure to see what's new. It works interactively by default and you're able to preview each SQL query to be run.
đš Usage:
npx linkedql migrate
npx linkedql migrate --db=database_1
Use the --desc
flag to provide the description for your new changes:
npx linkedql migrate --desc="Initial DB creation"
Use the flag --quiet
to turn off SQL previews:
npx linkedql migrate --quiet
linkedql rollback
Interactively perform a rollback. Linked QL looks for the next savepoint at each database and initiates a rollback. It works interactively by default and you're able to preview each SQL query to be run.
đš Usage:
npx linkedql rollback
npx linkedql rollback --db=database_1
Use the --direction
flag to specify either a "backward" rollback (the default) or a "forward" rollback if already at a certain rollback state:
npx linkedql rollback --direction=forward
Use the flag --quiet
to turn off SQL previews:
npx linkedql migrate --quiet
linkedql leaderboard
View the latest savepoint at each database. Linked QL displays details about the next savepoint at each database.
đš Usage:
npx linkedql leaderboard
npx linkedql leaderboard --db=database_1
Use the flag --direction
to specify either a "back in time" lookup (the default) or "forward in time" lookup if already at a certain rollback state:
npx linkedql leaderboard --direction=forward
linkedql refresh
Refresh local schema file. Linked QL regenerates the schema from current DB structure for each database it has managed and refreshes local copy.
đš Usage:
npx linkedql refresh
npx linkedql refresh --db=database_1
linkedql forget
Permanently erase savepoint histories. Linked QL deletes the savepoint history of all databases or specified database. This is irreversible.
đš Usage:
npx linkedql forget
npx linkedql forget --db=database_1
đŁ And that's a wrap!
Roadmap
- Implement support for IndexedDB.
- Implement the in-memory database.
Much of that could happen sooner with your support! If you'd like to help out, please consider a sponsorship. PRs are also always welcome.
Issues
To report bugs or request features, please submit an issue to this repository.
License
MIT.