This document defines APIs and behaviors of a relational database. The capabilities of the defined relational database are comparable to a subset of SQL-03 standard.
This document has not been submitted to anywhere yet.
Relational data manipulation for web apps has been in a difficult state for years. WebSQL has quite a few problems and is hence deprecated. IndexedDB, on the other hand, is an object database. In this document, we define a series of easy-to-use APIs that promote readable, maintainable, and performant code for apps needing relational data.
The APIs proposed in this document are designed for small-scale relational data manipulation. The following example shows how to open a database.
// Open database named 'hr'. // If the named instance does not exist, create an empty database instance. // Otherwise, open the existing database named 'hr'. let dbConnection; function connect() { let rdb = navigator['db']; return rdb.open('hr').then((connection) => { dbConnection = connection; // version is a read-only number that is for reference only. if (dbConnection.schema().version == 0) { // This is an empty database. return createDB(); } else if (dbConnection.schema().version < 2) { // Version is smaller than expected, perform upgrades. return upgradeDB(); } return dbConnection; }); } function createDB() { let tx = dbConnection.createTransaction('readwrite'); let q1 = dbConnection .createTable('Dept') .column(/* name */ 'id', /* type */ 'string', /* not null */ true) .column('name', 'string', true) .column('desc', 'string') .primaryKey('id'); let q2 = dbConnection .createTable('Emp') .column('id', 'integer', true) .column('name', 'string', true) .column('deptId', 'string', true) .column('title', 'string') .primaryKey('id') .index('idx_name', 'name', /* unique */ true) .index('idx_desc', { name: 'desc', order: 'desc' }) .foreignKey('fk_DeptId', 'deptId', 'Dept.id'); let q3 = dbConnection.setVersion(2); return tx.exec([q1, q2, q3]).then(() => { return dbConnection; }); } function upgradeDB() { return dbConnection .alterTable('Dept') .addColumn('desc', 'string') .addIndex('idx_desc', { name: 'desc', order: 'desc' }) .commit() .then(() => { return dbConnection; }); } connect().then(() => { // Real work starts here. });
Once the database is opened, queries can be performed.
let dbConnection; let dept = dbConnection.schema().table('Dept'); function insertData() { let deptData = [ { 'id': 'HR', 'name': 'Human Resources', 'desc': 'Rock stars' }, { 'id': 'ENG', 'name': 'Engineering', 'desc': 'Hard workers' }, { 'id': 'NADA', 'name': 'Non existing' }, { 'id': 'L', 'name': 'Leadership' } ]; return dbConnection.insert().into(dept).values(deptData).commit(); } function updateData() { return dbConnection .update(dept) .set(dept['desc'], 'Master minds') .where(dept['id'].eq('L')) .commit(); } function deleteData() { return dbConnection .delete() .from(dept) .where(dept['id'].eq('NADA')) .commit(); } function selectData() { return dbConnection.select().from(dept).commit(); } insertData().then(function () { return updateData(); }).then(function () { return deleteData(); }).then(function () { return selectData(); }).then(function (rows) { // Expected returns: // [{'id': 'HR', 'name': 'Human Resources', 'desc': 'Rock stars'}, // {'id': 'ENG', 'name': 'Engineering', 'desc': 'Hard workers'}, // {'id': 'L', 'name': 'Leadership', 'desc': 'Master minds'}] console.log(rows); });
The proposed spec defines the behaviors of a database. A database consists of two parts: storage and query engine. Data is stored in the storage as rows, each row is a tuple of values, and the types of values are defined as columns. Same class of rows are grouped together as a table. The database has a concept named schema to define columns inside tables, and the relations among rows. Query engine is responsible for reading and writing rows from the storage, enforcing relations defined in the schema, and providing various functions to manipulate how the rows are read and written.
A schema defines the storage structure of a relational database. A schema MUST have a name. The origin of the page and the schema name together uniquely identify a database instance during runtime.
A schema has a referential version number associated with it. Version number is an integer greater than zero and can be freely assigned. It has no effect on identifying a database. It is provided for the convenience of developers to version their schema without extra table.
A schema is structured in a hierachical way. A database schema contains one or more tables. A table contains one or more columns, zero or more indices, and zero or more constraints. The collection of columns in a table defines the fields carried by a row, which is the fundamental unit of data storage in relational database. Each column consists a name and an associated data type. The indices hint the database engine to build index structures so that subsequent queries can be acclerated. Index definitions MAY be honored if the database engine determined that they are necessary. The constraints define the relations of rows and ensures data integrity, and MUST be honored.
The database schema is constructed via builder pattern APIs. Schema can
be changed through schema queries within implicit transactions, or explicit
batched readwrite
transactions.
Names, also known as identifiers, are used to identify entities in schema, i.e. database, table, column, index, and constraint. All names used in the database schema MUST abide the following rules:
/^[A-Za-z][A-Za-z0-9_]*$/.test(name)
Object.prototype
and ITable.
Unlike SQL, names in RDB are case-sensitive.
A table column MUST be associated with a given type. Valid types are defined in ColumnType enum:
enum ColumnType { "blob", "boolean", "date", "integer", "number", "string", "object" }; typedef (ArrayBuffer or boolean or Date or long long or double or DOMString or object) ValueType; typedef (boolean or Date or long or double or DOMString) IndexableValueType;
blob represents JavaScript
ArrayBuffer
, and date
refers to Date
object.
boolean,
number,
string and
object correpond to the types of the same
names in JavaScript. The type number
is the Web IDL type
double
.
integer is a special data type
representing integers, corresponding to Web IDL type long long
,
but within the range of [Number#MIN_SAFE_INTEGER,
Number#MAX_SAFE_INTEGER]
. Integer columns will be converted to and from
JavaScript number
types using equivalent of
Number#toPrecision(1)
. If the converted number failed test of
Number#isSafeInteger()
, a DataError will be thrown.
JavaScript types that can be used in a column is defined as
ValueType. Only some value types are indexable,
indicating that they can be compared, sorted, and used in indices and
predicates. These types are defined as IndexableValueType.
Non-indexable types, blob and
object, MUST NOT be used to
create indices and predicates, except the
isNull()
and isNotNull()
predicates.
There are two sorting orders defined in the Order enum:
enum Order { "asc", "desc" };
The asc represents ascending sorting order, and is the default sorting order if none were provided. The desc represents descending sorting order.
Ascending sort order is the natural sort order, which means that if two
fields are ordered as a, b
, then (a <= b) === true
.
Since blob
and object
types are not
indexable, they cannot be compared and thus the orders have no effect
for these two types.
A database instance is created using schema queries. The schema of a database instance can also be altered via schema queries. Schema queries are run in the context of transaction and can be executed any time during the life time of the database.
IExecutionContext returned from ISchemaQueryProvider or its methods can only be used in implicit transactions or batch-executed explicit transactions, i.e. ITransaction.exec().
Any name used inside the schema not obeying Naming Rules will result in raising InvalidSchemaError during the execution of schema queries.
The ISchemaQueryProvider is the interface of manipulating database-level schema:
interface ISchemaQueryProvider { IExecutionContext setVersion(unsigned short version); IExecutionContext setForeignKeyCheck(boolean value); IDatabaseSchema schema(); ITableBuilder createTable(DOMString tableName); ITableChanger alterTable(DOMString tableName); IExecutionContext dropTable(DOMString tableName); };
IExecutionContext
setVersion(unsigned short version);
version
MUST NOT be set to
0
, otherwise InvalidSchemaError will be raised.IExecutionContext
setForeignKeyCheck(boolean value);
IDatabaseSchema
schema();
ITableBuilder
createTable(DOMString tableName);
ITableChanger
alterTable(DOMString tableName);
tableName
MUST already
exist in the database, otherwise a DataError will be raised.IExecutionContext
dropTable(DOMString tableName);
name
MUST already exist in the database, otherwise a
DataError will be raised.IDatabaseSchema represents a database.
interface IDatabaseSchema { readonly attribute DOMString name; readonly attribute unsigned short version; ITable table(DOMString tableName); };
DOMString
name
unsigned short
version
ITable
table(DOMString tableName);
tableName
MUST
already exist in the database, otherwise a DataError will be
raised.ITableBuilder is used to describe the new table that will be created.
interface ITableBuilder : IExecutionContext { ITableBuilder column(DOMString name, ColumnType type, optional boolean notNull = false); ITableBuilder primaryKey((DOMString or sequence< DOMString >) columns, optional boolean autoIncrement = false); ITableBuilder foreignKey(DOMString name, (DOMString or sequence< DOMString >) column, (DOMString or sequence< DOMString >) foreign, optional ForeignKeyAction action = "restrict", optional ForeignKeyTiming timing = "immediate"); ITableBuilder index(DOMString name, IndexedColumnDefinition columns, optional boolean unique = false); };
All methods returns the same ITableBuilder object that
they were called from to make cascade builder pattern possible. If any of
the rule described in the following table were violated, an
InvalidSchemaError will be thrown. All methods can be called
multiple times except primaryKey
.
ITableBuilder
column(DOMString name,
ColumnType type, optional boolean notNull = false);
name
MUST be unique within the
containing table. NOT NULL means the column MUST NOT have a
value of undefined
or null
. When
notNull
is set to true
, the column is treated as
NOT NULL.ITableBuilder
primaryKey((DOMString
or sequence< DOMString >) columns, optional boolean autoIncrement = false);
autoIncrement
is set to true
, there MUST be only
one column in the column
field with integer
column type. An auto-increment key will start from zero. When a new row is
inserted into the table, the key will increase by one and assigned to that
row regardless the original key value given to the row. If the key grows
beyond the integer range Number#MAX_SAFE_INTEGER
, it will be
reset to 0
and start over. If a column is designated as
auto-increment primary key, its values become read-only and MUST NOT be
updatable by the user (i.e. users cannot call update()
on
that column). The next auto-increment value cannot be reset even when all
rows are deleted from that table.ITableBuilder
foreignKey(DOMString name,
(DOMString or sequence< DOMString >) column, (DOMString or sequence< DOMString >) foreign,
optional ForeignKeyAction action = 'restrict',
optional ForeignKeyTiming timing = 'immediate');
ITableBuilder
index(DOMString name,
IndexedColumnDefinition columns, optional boolean unique = false);
name
is the name of the
index, and it MUST be unique within the containing table. The
columns
allows multiple type of definitions. If provided as
DOMString
, the string MUST refer to an
indexable column of the containing table; if provided as
sequence< DOMString >
, it MUST NOT be empty, and the strings MUST
refer to different indexable columns of the containing
table. In these two cases, default sort order of asc
is
used. If provided as sequence< IndexedColumnSpec >
, it MUST NOT
be empty, and each IndexedColumnSpec
MUST refer
to different column. The optional unique
parameter specifies
whether keys in index are unique or not, default to false
.This specification does not support named primary key. Moreover, primary keys can only have ascending order.
An index can be keyed by one or more columns. A unique key
means that the value or combination of values used as index key is unique
within the index. By default, all indices allow duplicate keys unless the
unique
is set to true. A ConstraintError will be thrown
if user tried to insert/update a row with duplicated key when a unique
index is specified.
The WebIDL below defines indexed column specification, IndexedColumnSpec and IndexedColumnDefinition:
dictionary IndexedColumnSpec { DOMString name; Order order = "asc"; }; typedef (DOMString or sequence< DOMString > or IndexedColumnSpec or sequence< IndexedColumnSpec >) IndexedColumnDefinition;
name
order
asc
.If an index definition overlapped with implicit indices created by constraints (for example, create an index on the same column of the primary key), it will be up to the query engine to determine whether or not to create this index.
Cross-table indices or views are not supported in this specification.
Foreign keys provides referential integrity, which means that a value in a column MUST match the values appearing in some row of another table. A foreign key is defined with five parameters:
name
is a unique name within the containing table that
identifies this constraint.column
is the name of local (referring) column(s).
It MUST be indexable.foreign
is the full name of the remote (referred)
column(s). It MUST consist a unique index or primary key. The number of
columns and column types MUST exactly match what are provided in the
column
parameter.action
is an optional parameter, and default to
restrict
.timing
is an optional paramter, and default to
immediate
.A chainned foreign key means the remote
column
is also a local
column of another foreign key, which forms a
dependency. The query engine MAY support chainned foreign key. The query
engine MAY support referred column to be within the containing table.
ForeignKeyAction enum is defined as following:
enum ForeignKeyAction { "restrict", "cascade" };
restrict
cascade
ForeignKeyTiming enum is defined below:
enum ForeignKeyTiming { "immediate", "deferrable" };
immediate
deferrable
This specification does NOT support granuarity of cascade,
e.g. ON DELETE UPDATE ON UPDATE NO_ACTION
. It also does NOT
support granuarity of deferral, e.g.
DEFERRABLE INITIALLY IMMEDIATE
.
ITableChanger is used to change the schema of an existing table, and MUST be used within the context of a transaction. If the table already contains data, altering table schema can cause constraint violations and fail the transaction.
interface ITableChanger : IExecutionContext { ITableChanger rename(DOMString newTableName); ITableChanger addColumn(DOMString name, ColumnType type, optional boolean notNull = false, optional ValueType defaultValue = null); ITableChanger dropColumn(DOMString name); ITableChanger addPrimaryKey((DOMString or sequence< DOMString >) columns); ITableChanger addForeignKey(DOMString name, (DOMString or sequence< DOMString >) column, (DOMString or sequence< DOMString >) foreign, optional ForeignKeyAction action = "restrict", optional ForeignKeyTiming timing = "immediate"); ITableChanger addIndex(DOMString name, IndexedColumnDefinition columns, optional boolean unique = false); ITableChanger dropConstraintOrIndex(DOMString name); IColumnChanger setColumn(DOMString name); };
All methods returns the same ITableChanger object that
they were called from to make cascade builder pattern possible, except that
setColumn
will return a IColumnChanger instead. If any
of the rule described in the following table were violated, an
InvalidSchemaError will be thrown. All methods can be called
multiple times.
ITableChanger
rename(DOMString newTableName);
newTableName
MUST be unique within the
database, and MUST NOT be the same as existing table name.ITableChanger
addColumn(DOMString name, ColumnType type, optional boolean notNull = false, optional ValueType defaultValue = null);
name
MUST be unique within
the containing table. If defaultValue
is given, it MUST
be of the same type as specified in type
. Every existing row
in the table will be added a new column with defaultValue.
ITableChanger
dropColumn(DOMString name);
name
MUST refer
to an existing column in the table schema. The referred column MUST NOT
be the only column in the table schema, and MUST NOT be referred by any
index nor constraints. Every existing row in the table will be modified
to remove the field represented by that column.ITableChanger
addPrimaryKey((DOMString or sequence< DOMString >) columns);
ITableChanger
dropPrimaryKey();
ITableChanger
addForeignKey(DOMString name, (DOMString or sequence< DOMString >) column, (DOMString or sequence< DOMString >) foreign, optional ForeignKeyAction action = 'restrict', optional ForeignKeyTiming timing = 'immediate')
ITableChanger
addIndex(DOMString name, IndexedColumnDefinition columns, optional boolean unique = false);
name
is the name
of the index and MUST be unique within the containing table. If
unique
is set to true, add a unique index.ITableChanger
dropConstraintOrIndex(DOMString name);
name
MUST refer to an existing constraint or index.IColumnChanger
setColumn(DOMString name);
name
MUST
refer to an existing column in the table schema.This specification does not support adding or dropping auto increment primary key.
Column changer, modeled as IColumnChanger, is defined as following:
interface IColumnChanger { ITableChanger set(DOMString newColumnName, optional boolean notNull = true); };
ITableChanger
set(DOMString newColumnName, optional boolean notNull = true);
ITableChanger
is the parent object that creates this object.
Changing column data type is not supported by this spec. JavaScript is famous of tricky type conversions and it is unlikely to have conversion rules that covers every corner case.
The following example demonstrates how to create a database schema.
let dbConnection; let tx = dbConnection.createTransaction('readwrite'); let q1 = dbConnection .createTable('Dept') .column(/* name */ 'id', /* column_type */ 'string', /* not_null */ true) .column('name', 'string', true) .primaryKey('id'); let q2 = dbConnection .createTable('Emp') .column('id', 'number', true) .column('name', 'string', true) .column('deptId', 'string', true) .column('title', 'string') .primaryKey('id') .index('idx_Desc', { 'name': 'desc', 'order': 'desc' }) .foreignKey('fk_DeptId', 'deptId', 'Dept.id'); tx.exec([q1, q2]).then(() => { // Table created, do something here. });
The following example shows how to alter database schema during schema upgrade.
let dbConnection; let version = dbConnection.schema().version; if (version < 2) { // Need DB upgrade. let tx = dbConnection.createTransaction('readwrite'); let setVersion = dbConnection.setVersion(2); let createNewTable = dbConnection .createTable('NewTable') .column('id', 'string', true) .column('name', 'string', true); let alterTable = dbConnection .alterTable('Emp') .addColumn('location', 'string', true, 'LAX') .setColumn('title').set('title', true) // Change to NOT NULL .addIndex('idx_location', 'location'); let dropTable = dbConnection.dropTable('Foo'); // MUST use exec() instead of attach(). return tx .exec([setVersion, createNewTable, alterTable, dropTable]) .then(() => { // Schema change has finished, start work here. }); }
A database's origin is the same as the origin of the document or worker. Each origin has an associated set of databases.
The database origin is not affected by changes to
document.domain
.
Every database has a predefined layout of the data structures persisted, which is called a schema. The database name specified in schema identifies the database within a specific origin. The name MUST satisfy the Naming Rules, and stay constant for the lifetime of the database. Each database also has a version number for reference only.
Databases have a delete pending flag which is used during deletion. When a database is requested to be deleted the flag is set to true and all attempts at opening the database are stalled until database is deleted.
The act of opening a database creates a connection. There MAY
be multiple connections to a given persistent
database at any
given time, where temporary
database accepts only one connection.
Each connection has a closePending flag which initially is set to
false.
When a connection is initially created it is in opened state. The connection can be closed through several means. If the connection is garbage collected or the execution context where the connection was created is destroyed (for example due to the user navigating away from that page), the connection is closed. The connection can also be closed explicitly via the API. When the connection is closed the closePending flag is always set to true regardless of its previous state. All uncommitted transactions associated with a connection that is marked close pending will be canceled.
Users can create observers associated with a given database. Each observer consists of a select query. When the results of that select query change, the observer will be triggered. The change MAY come from other connections. When the connection associated with a registered observer is marked as close pending, the observer MUST be unregistered automatically.
The relational database is part of Navigator
as defined in
following WebIDL.
partial interface Navigator { readonly attribute IRelationalDatabase db; };
IRelationalDatabase db
The relational database is defined as IRelationalDatabase:
interface IRelationalDatabase { readonly attribute IDatabaseFunctionProvider fn; Promise< DatabaseConnection > open(DOMString name, OpenDatabaseOptions? opt); Promise< void > drop(DOMString name); };
IDatabaseFunctionProvider
fn
Promise< DatabaseConnection >
open(DOMString name, OpenDatabaseOptions? opt)
OpenDatabaseOptions
is not
specified, a persistent
database with the given name will be
opened/created.Promise< void >
drop()
name
. This MUST follow
the delete database algorithm. If
the database does not exist, the returned promise will resolve immediately.
If the database is marked delete pending, the promise will resolve
immediately.OpenDatabaseOptions is a dictionary object used to specify options for opening a database:
dictionary OpenDatabaseOptions { RDBStorageType storageType; };
RDBStorageType
storageType
storageType
is fixed for the lifetime of returned database
connection.enum RDBStorageType { "persistent", "temporary" };
persistent
temporary
The delete database algorithm is performed in following steps:
The database connection is the means by which the browser session and underlying database communicate with each other. IDatabaseConnection models a database connection:
interface IDatabaseConnection { ITransaction createTransaction(optional TransactionMode mode = "readonly"); Promise< Error > close(); IBindableValue bind(unsigned short index); };
ITransaction
createTransaction(optional TransactionMode mode = "readonly");
Promise< Error >
close();
IBindableValue
bind(unsigned short index);
The actual database connection returned from Navigator is a class object, DatabaseConnection, that implements four different interfaces:
interface DatabaseConnection : IDatabaseConnection { readonly attribute DOMString name; }; DatabaseConnection implements IDatabaseObserver; DatabaseConnection implements IDataQueryProvider; DatabaseConnection implements ISchemaQueryProvider;
name
name
field specified in schema.The ISchemaQueryProvider provides database-level schema query and allows creating or changing the database schema. The IDataQueryProvider provides APIs to construct data manipulation query to be used to manipulate data within a transaction. The IDatabaseObserver provides change observation for the results of a select query, regardless the changes are from this connection or not.
A transaction is an atomic unit of reading and writing data in a database. The term atomic means it is guaranteed that all operations inside this transaction to be committed as a whole, or none get committed. A committed transaction guarantees all data being written to storage. The operations inside a transaction are referred as queries. A query means an interaction with the database: data retrieval, data persistence, or change of schema.
Transaction execution behavior is modeled as the following: A TransactionResults to represent the return values from a finished transaction, and IExecutionContext to control the transaction lifetime.
typedef (void or sequence< object >) TransactionResults; interface IExecutionContext { Promise< TransactionResults > commit(); Promise< void > rollback(); };
Promise< TransactionResults >
commit();
Promise< void >
rollback();
All transactions are associated with a database connection, which is the connection that the transaction is created within. A transaction has a predetermined TransactionMode to indicate what access this transaction is asking for. The mode is set when the transaction is created and remains fixed for the life of the transaction. There are two transaction modes:
enum TransactionMode { "readonly", "readwrite" };
readonly
readwrite
A transaction has a scope that determines the rows affected by this transaction. The scope is automatically calculated. The scope of a transaction MAY dynamically change during the lifetime of the transaction.
A transaction can be created explicitly or implicitly. An explicit
transaction is created directly from
IDatabaseConnection.createTransaction()
. An implicit transaction
is created when the commit algorithm needs
one. The explicit transaction is defined as ITransaction interface:
interface ITransaction : IExecutionContext { Promise< void > begin(); Promise< TransactionResults > exec(sequence< IExecutionContext > queries); Promise< TransactionResults > attach(IExecutionContext query); };
Promise< void >
begin();
commit()
or rollback()
of this
transaction is triggered, or the transaction is timed out by implementation
(which the implementation MUST throw TimeoutError and reject any
further calls on this transaction object).Promise< TransactionResults >
exec(sequence< IExecutionContext > queries);
queries
will be
executed in the order they appear in the sequence, and the transaction will
be auto-committed after all sequence has run. If any of the query failed,
or TimeoutError happens, the transaction will be automatically rolled
back.Promise< TransactionResults >
attach(IExecutionContext query);
This specification does not support nested transactions.
The lifetime of an explicit transaction is controlled by the user.
There are two ways to start execution: batch mode and
sequence mode. In batch mode, an explicit transaction is started
with exec(queriesArray)
and returns
a result Promise
. All queries will be executed sequentially in
the order received, and the results of last query in the transaction are
returned in the promise. In sequence mode, user calls begin()
to
start the transaction, calls attach(query)
to attach a query to
the transaction's execution context. The user can call commit()
to indicate the end of transaction, and a final result Promise
will be returned; or the user can call rollback()
to abort the
transaction, and all modifications done by previous attached queries will be
discarded.
When a transaction is committed or rolled back, it is said to be finished. A finished transaction MUST NOT be started nor rolled back. If a transaction's connection is lost before transaction being finished, the transaction is effectively rolled back and no change will be persisted.
Actual time out length for a given transaction is implementation dependent. Implementation MAY choose an appropriate value, or just set it to indefinite.
An IExecutionContext can be obtained from IQuery or ISchemaQueryProvider. The execution context is not associated with any ITransaction object in the beginning, which means it is an unbound context. When the IExecutionContext.commit() is called, an implicit transaction will be associated with the execution context. When ITransaction.attach() is called, the execution context passed as parameter will be associated with the transaction being called. In both cases, the context is a bound context, indicating existence of an associated transaction. An execution context MUST be associated with a transaction before execution.
The commit algorithm is described in following steps:
readonly
transaction mode, create a transaction using that mode; otherwise create
a readwrite
transaction. Associate the transaction with the
execution context.rollback
.
R
of the last execution
context in the associated transaction from query engine.R
.R
.R
resolves or rejects, mark transaction as
finished.The rollback algorithm is described in following steps:
A snapshot of the database is the contents of all rows in the database at the time when the snapshot is taken. At any given time, query engine MUST guarantee only one connection can create snapshot on a given scope. Conceptually, each transaction operates on its own snapshot of database, and the snapshot is taken at the beginning of transaction. The beginning of transaction is determined by either calling ITransaction.begin or ITransaction.exec for explicit transactions, or IExecutionContext.commit for implicit transactions.
All changes performed by queries inside the transaction will only be
effective on that snapshot. The end of transaction is determined by
resolution or rejection of the Promise
returned from
ITransaction.exec or IExecutionContext.commit. If the
transaction is committed, i.e. resolution of Promise
, the
database MUST be updated to that snapshot. If the transaction is rolled
back, i.e. rejection of Promise
, the snapshot is discarded.
The final data persisted on database is determined by the last transaction snapshot committed by the query engine. The transaction snapshots MAY come from other sessions other than current session. The query engine MUST guarantee only one snapshot can be persisted at a given time if there were multiple snapshots on a given scope. If there were multiple snapshots on a given scope, and one of them is persisted while the others are open, all the opened snapshots will be invalidated and their associated transactions must be rolled back.
It's up to the implementation to determine whether a table scope or a row scope to be used in transaction snapshots. The minimum requirement for this specification is to support table scope, i.e. different transactions MUST be able to operate on different tables simultaneously.
Observers monitor row changes inside a database. If the results from registered query change due to data changes in the database, the callback in corresponding observer will be invoked. Observer management are performed through IDatabaseObserver interface:
callback observerCallback = void (); interface IDatabaseObserver { DOMString observe(ISelectQuery query, observerCallback callbackFn); void unobserve(DOMString observerKey); };
DOMString
observe(ISelectQuery query, observerCallback callbackFn);
query
changes
due to other data manipulations, observerCallback defined by the
user will be invoked.void
unobserve(DOMString observerKey);
Although observers are registered/unregistered via a database connection object, the changes they receive may come from other connections. The callback function is only a signal of query result change, and the users are responsible to re-execute observed query in a transaction if desired. Unlike trigger in relational databases, observers do not require implementations to execute the observed query.
The specification supports observations only for single select queries, not
for readonly
transactions.
Data manipulation of a database is carried out through transactions and the queries within. In this section, the APIs for constructing queries will be detailed. Due to the complexity, data selection will be discussed in its own section.
The fundamental part of data manipulation is to specify what to
manipulate. In a relational database, this implies table and columns in the
schema. The schema objects for data manipulation are acquired through
IDatabaseSchema objects obtained from
ISchemaQueryProvider.schema()
offered by
DatabaseConnection.
The table objects returned by IDatabaseSchema.table()
implements the ITable interface. These table objects MUST have
properties with same names as columns of that table, and the properties are
Column objects, see column
representation for more details.
interface ITable { ITable as(DOMString alias); DOMString getName(); DOMString getAlias(); };
ITable
as(DOMString alias)
DOMString
getName()
DOMString
getAlias()
null
is returned.It is a delibrate design to use getName()
and getAlias()
in ITable
, because the string-based
properties of ITable
are IColumn
objects. In
order to create less name conflicts, the get functions are used.
Column representation objects have a base interface as defined in the IColumn interface:
interface IColumn { readonly attribute DOMString name; readonly attribute DOMString table; readonly attribute ColumnType type; readonly attribute DOMString fullName; readonly attribute boolean nullable; IColumn as(DOMString alias); };
name
table
type
fullName
`${ITable#getName}.${IColumn.name}`
, otherwise
`${ITable#getAlias}.${IColumn.name}`
.nullable
false
, the column is NOT NULLIColumnas(DOMString alias);
The objects also provide interfaces for creating predicates, therefore they are defined by the Column interface.
interface Column : IColumn { }; Column implements IComparisonPredicate; Column implements ITruthPredicate;
Bindable values provides flexibilities for determining the actual values to be used in queries at run-time. They serve as placeholders of values. Bindable values are typed as IBindableValue:
interface IBindableValue { readonly attribute unsigned short index; };
index
bind()
code.
One can bind at most 255 bindable values for any given statement. This
limitation is created for simpler implementation.Type check of bound value is performed by query engine before executing the query. To assign value to IBindableValue, caller MUST use IQuery.bind prior to query execution. A query with any unassigned IBindableValue, or any assigned IBindableValue with mismatched type, MUST be rejected with BindingError during its execution.
The following example demonstrate how to perform parameterized query using data binding:
let dbConnection; let query; function init() { let dept = dbConnection.schema().table('Dept'); let emp = dbConnection.schema().table('Employee'); query = dbConnection .select(emp['name'].as('ename'), dept['name']) .from(dept, emp) .where(dept['id'].eq(emp['id']).and(emp['id'].eq(dbConnection.bind(0)))); } function updateModel(employeeId) { query.bind(employeeId).commit().then(rows => { console.log(rows[0]['ename'], rows[0]['Department.name']); }); }
Data manipulation queries are created from the IDataQueryProvider interface, which is implemented by the DatabaseConnection object.
interface IDataQueryProvider { ISelectQuery select(IColumn... columns); IInsertQuery insert(); IInsertQuery insertOrReplace(); IUpdateQuery update(ITable table); IDeleteQuery delete(); };
ISelectQuery
select(IColumn... columns);
IInsertQuery
insert();
IInsertQuery
insertOrReplace();
IUpdateQuery
update(ITable table);
IDeleteQuery
delete();
All queries MUST inherit from this base IQuery interface.
interface IQuery : IExecutionContext { Promise< DOMString > explain(); IQuery bind(any... values); IQuery clone(); DOMString toSql(); };
Promise< DOMString >
explain();
IQuery
bind(any... values);
IQuery
clone();
DOMString
toSql();
null
if the
implementation does not support this feature. The specification recommends
SQL-03 standard for generating SQL statements.Insert queries are used to insert rows into tables. There are two
variations of insert queries: insert
and
insertOrReplace
. Both variations support the
IInsertQuery interface:
interface IInsertQuery : IQuery { IInsertQuery into(ITable table); IInsertQuery values((object or sequence< object > or IBindableValue) rows); };
IInsertQuery
into(ITable table);
insertOrReplace
, the table
MUST have a primary
key, otherwise IntegrityError will be thrown.IInsertQuery
values((object or sequence< object > or IBindableValue) rows);
object
as parameter, or the bindable value resolved as an object, the object will
act as a single row; otherwise, each element in the given array is treated
as an individual row.All methods of IInsertQuery returns the calling
IInsertQuery
object for cascade builder pattern. The methods MUST
be called once and only once per query before its execution, otherwise
SyntaxError MUST be raised.
If an IInsertQuery object is created from insert
,
and a row with duplicated primary key is given, ConstraintError
MUST be thrown if query engine detected integrity violation. On the other
hand, if the IInsertQuery object is from insertOrReplace
,
the offended row MUST be silently replaced by the new row provided,
effectively as removing the offended row and insert the offending row.
If any of the inserted row causes integrity violation other than primary key, a ConstraintError MUST be thrown regardless.
If any of the given rows does not exactly match the table schema of the table specified, DataError MUST be raised. Exact match means that each column specified in the table schema MUST appear as a property of any object provided as row.
IBindableValue can also resolve as an array of objects to provide multiple rows in one call.
Delete queries are used to delete rows in a table. The rows to be deleted
are specified via Search Condition. If the
search condition is not provided, all rows in the table specified by
from()
will be deleted. The delete query implements the
IDeleteQuery interface:
interface IDeleteQuery : IQuery { IDeleteQuery from(ITable table); IDeleteQuery where(ILogicalPredicate searchCondition); };
IDeleteQuery
from(ITable table);
IDeleteQuery
where(ILogicalPredicate searchCondition);
All methods of IDeleteQuery returns the calling
IDeleteQuery
object for cascade builder pattern. The methods can
only be called once per query, otherwise SyntaxError MUST be raised.
If the table is empty, or the search condition returns no matching rows, the delete query will finish successfully without doing anything. If deleting rows cause data integrity violation (for example, deleting a row that is referred in a foreign key), a ConstraintError MUST be raised.
Search condition specifies how the query engine should search the rows for
query. They are represented by logical predicates, will be evaluated for
rows in the database during query execution, and returns true
if
the evaluated row is a match or false
otherwise. Each
logical predicate implicitly carries a scope, which defines the tables
that the predicate shall be evaluated against. All rows in these tables will
be evaluated. The query engine MAY apply speed up techniques to accelerate
the evaluation, for example, using indices.
Logical predicates are obtained from either comparison, truth, or logical
predicates. Comparison and truth predicates are obtained from IColumn
objects. Complement of predicate is provided by the not
function in IDatabaseFunctionProvider.
The comparison or truth predicates can only compare the column type of
the column associated. An implicit typeof
will be called against
the provided value to get the value type. If the provided value type is not
the same as the column type, a TypeError will be thrown.
This spec does not provide implicit type conversion. All types MUST be explicit.
The comparison predicates evaluates the relation between a column's value and the given target. Only indexable data types, denoted as IndexableValueType, and their binding counterparts, can be compared. These types are denoted as ComparableValueType and constructs the fundamentals of IComparisonPredicate:
typedef (boolean or Date or number or DOMString) IndexableValueType; typedef (IndexableValueType or IBindableValue) ComparableValueType; interface IComparisonPredicate { ILogicalPredicate eq((ComparableValueType or IColumn) value); ILogicalPredicate neq((ComparableValueType or IColumn) value); ILogicalPredicate lt((ComparableValueType or IColumn) value); ILogicalPredicate lte((ComparableValueType or IColumn) value); ILogicalPredicate gt((ComparableValueType or IColumn) value); ILogicalPredicate gte((ComparableValueType or IColumn) value); };
ILogicalPredicate
eq((ComparableValueType or IColumn) value);
==
operator.ILogicalPredicate
neq((ComparableValueType or IColumn) value);
!=
operator.ILogicalPredicate
lt((ComparableValueType or IColumn) value);
<
operator.ILogicalPredicate
lte((ComparableValueType or IColumn) value);
<=
operator.ILogicalPredicate
gt((ComparableValueType or IColumn) value);
>
operator.ILogicalPredicate
gte((ComparableValueType or IColumn) value);
>=
operator.Giving IColumn as the parameter of
IComparisonPredicate creates implicit inner
join syntax. The search condition given by this predicate MUST be
evaluated as if given in the form of innerJoin
. If the given
IColumn is of different type other than the associated column of
IComparisonPredicate, a TypeError MUST be thrown.
The truth predicates evaluate whether the given condition is true for the associated column or not. They are defined by ITruthPredicate:
interface ITruthPredicate { ILogicalPredicate between(ComparableValueType lhs, ComparableValueType rhs); ILogicalPredicate startsWith((IBindableValue or DOMString) value); ILogicalPredicate endsWith((IBindableValue or DOMString) value); ILogicalPredicate in((sequence< ComparableValueType > or IBindableValue or ISelectQuery) values); ILogicalPredicate isNull(); ILogicalPredicate isNotNull(); };
ILogicalPredicate
between(ComparableValueType lhs, ComparableValueType rhs);
<=
and
>=
.ILogicalPredicate
startsWith((IBindableValue or DOMString) value);
ILogicalPredicate
endsWith((IBindableValue or DOMString) value);
ILogicalPredicate
in((sequence< ComparableValueType > or IBindableValue or ISelectQuery) values);
values
. Equality is evaluated using the rule of JavaScript
===
operator. If the values
are specified as an
ISelectQuery, the given query will be evaluated first and generate
values from its projection list. If the projection list is of different
data type, the implementation will throw TypeError.ILogicalPredicate
isNull();
null
or undefined
.ILogicalPredicate
isNotNull();
null
nor
undefined
.Like SQL, empty string is not null.
This spec does not support constant expressions and therefore IComparisonPredicate and ITruthPredicate MUST be associated with an IColumn object.
Logical predicate is the representation of search condition. Logical predicates implement ILogicalPredicate:
interface ILogicalPredicate { ILogicalPredicate and(ILogicalPredicate... childPredicate); ILogicalPredicate or(ILogicalPredicate... childPredicate); ILogicalPredicate clone(); };
ILogicalPredicate
and(ILogicalPredicate... childPredicate);
AND
of logical predicates. The returning logical
predicate will evaluate to true if and only if all predicates in its
parameter are evaluated true.ILogicalPredicate
or(ILogicalPredicate... childPredicate);
OR
of logical predicates. The returning logical
predicate will evaluate to true if any of the predicates in its parameter
is evaluated true.ILogicalPredicate
clone();
The NOT
operation is provided by
IDatabaseFunctionProvider.not().
The predicate scope is the union of all tables involved for
creating the predicate. For example, IColumn.eq()
creates an
ILogicalPredicate, and scope of that predicate is the table
associated with the creating IColumn object.
The predicates can be complemented using a global function
not()
. The function is provided in IDatabaseFunctionProvider:
interface IDatabaseFunctionProvider : IAggregateFunction { ILogicalPredicate not(ILogicalPredicate predicate); };
ILogicalPredicate
not(ILogicalPredicate predicate);
false
if the predicate evaluated true, and vice versa.Select queries that can be evaluated into
IndexableValueType or sequence< IndexableValueType >
can
be used in predicates as ComparableValueType values. The evaluated
type MUST be identical as the type requested by predicate. The query
engine MUST evaluate these subqueries first and automatic bind the values
into the main query.
let dbConnection; function getDepartmentsWithFounders() { let dept = dbConnection.schema().table('Department'); let emp = dbConnection.schema().table('Employee'); return dbConnection .select(dept.name) .from(dept) .where(dept['id'].in( dbConnection .select(emp['deptId']) .from(emp) .where(emp['id'].lt(12)))) .commit(); }
Update queries updates the value of a column in all rows that matches the search condition. If the search condition is not provided, then the update will be applied to all rows of the table. Update queries are modeled in IUpdateQuery interface:
interface IUpdateQuery : IQuery { IUpdateQuery set(IColumn column, ValueType value); IUpdateQuery where(ILogicalPredicate searchCondition); };
IUpdateQuery
set(IColumn column, ValueType value);
IUpdateQuery
where(ILogicalPredicate searchCondition);
Data selection is done via select queries, which select rows matching
search conditions or join conditions from database. If the search conditions
are not specified, Cartesian product of all
rows from the tables specified by from()
will be returned.
Scope of select queries MUST be evaluated using select scope algorithm before execution. The select queries implement ISelectQuery interface:
interface ISelectQuery : IQuery { ISelectQuery from(ITable... tables); ISelectQuery where(ILogicalPredicate searchCondition); ISelectQuery innerJoin(ITable table, ILogicalPredicate joinCondition); ISelectQuery leftOuterJoin(ITable table, ILogicalPredicate joinCondition); ISelectQuery limit((unsigned long or IBindableValue) numberOfRows); ISelectQuery skip((unsigned long or IBindableValue) numberOfRows); ISelectQuery orderBy(IColumn column, optional Order order); ISelectQuery groupBy(IColumn... column); ISelectQuery union(ISelectQuery... query); ISelectQuery intersect(ISelectQuery... query); ISelectQuery except(ISelectQuery... query); };
ISelectQuery
from(ITable... tables);
ISelectQuery
where(ILogicalPredicate searchCondition);
ISelectQuery
innerJoin(ITable table, ILogicalPredicate joinCondition);
ISelectQuery
leftOuterJoin(ITable table, ILogicalPredicate joinCondition);
ISelectQuery
limit((unsigned long or IBindableValue) numberOfRows);
0
, the query is effectively an no-op. This function can only
be called once per query, otherwise SyntaxError will be thrown.ISelectQuery
skip((unsigned long or IBindableValue) numberOfRows);
ISelectQuery
orderBy(IColumn column, optional Order order);
ISelectQuery
groupBy(IColumn... column);
ISelectQuery
union(ISelectQuery... query);
ISelectQuery
intersect(ISelectQuery... query);
ISelectQuery
except(ISelectQuery... query);
There is not a hard rule regarding the invocation order of ISelectQuery member functions. However, it is recommended that the functions are invoked in the order of their appearance order in the interface for better readability.
The Cartesian product of two tables is calculated using the following algorithm.
The scope of a select query is determined by the following algorithm.
from()
innerJoin()
existed in query:leftOuterJoin()
existed in query:where()
existed in query:orderBy()
, groupBy()
,
or the projection list passed in the constructor of ISelectQuery
object is not in the valid projection scope, throw SyntaxError.The sorting algorithm is represented in the pseudo JavaScript code below.
var orderBySpecs; // Array of orderBy() specs given in this query.
filteredRows.sort(function(lhs, rhs) {
for (var i = 0; i < orderBySpecs.length; ++i) {
var spec = orderBySpecs[i];
var col = spec.column;
if (lhs[col] == rhs[col]) {
continue;
}
if ((spec.order == 'asc' && lhs[col] < rhs[col]) ||
(spec.order == 'desc' && lhs[col] > rhs[col])) {
return -1;
} else {
return 1;
}
}
return 0;
});
Inner joins create a new result table by combining column values of two
tables involved in the join based upon the predicate given as join
condition. Inner joins MAY be specified either implicitly through search
conditions, or explicitly via innerJoin()
. The following
example demonstrates two equivalent inner join syntaxes:
let dbConnection; let tableA = dbConnection.schema().table('A'); let tableB = dbConnection.schema().table('B'); // Implicit inner join. let q1 = dbConnection .select(tableA['key'], tableA['value'], tableB['timestamp']) .from(tableA, tableB) .where(tableA['key'].eq(tableB['key'])); // Explicit inner join. let q2 = dbConnection .select(tableA['key'], tableA['value'], tableB['timestamp']) .from(tableA) .innerJoin(tableB, tableA['key'].eq(tableB['key']));
The results of inner join MUST be equivalent to the outcome of the following algorithm:
Conceptually the inner join will create a new table. Implementation MAY NOT create actual table as long as the returned results are correct.
The user is not allowed to mix implicit and explicit join. Implementations will throw either SyntaxError or RuntimeError for invalid queries violating this rule.
This specification supports only left outer join, which preserves the
unmatched rows from the first (left) table, joining them with a
NULL
row in the shape of the second (right) table.
The algorithm is described below:
null
.Aggregate functions are used only in the projection list of a
select
query. The aggregation functions supported are defined in
IAggregateFunction:
interface IAggregateFunction { IColumn avg(IColumn col); IColumn count(optional IColumn col = null); IColumn distinct(IColumn... col); IColumn max(IColumn col); IColumn min(IColumn col); IColumn sum(IColumn col); };
IColumn avg(IColumn col);
col
for all rows grouped. If all rows are null
,
returns null
. The returned column is always typed as
number
. If the col
is not typed as
number
, TypeError will be thrown.IColumn count(IColumn col);
col
is NOT null
,
returns number of rows in the group whose col
is not null,
otherwise returns the total number of rows in the group.IColumn distinct(IColumn... col);
null
is considered as a unique value.IColumn max(IColumn col);
col
for all rows
grouped. The maximum value is determined by the last value of the
orderBy()
performed on that column. The max()
function returns null
if and only if all values grouped are
null
. The type of col
MUST be either
number
, string
, or date
, otherwise
TypeError will be thrown.IColumn min(IColumn col);
col
for all rows
grouped. The minimum value is determined by the first value of the
orderBy()
performed on that column. The max()
function returns null
if and only if all values grouped are
null
. The type of col
MUST be either
number
, string
, or date
, otherwise
TypeError will be thrown.IColumn sum(IColumn col);
col
for all rows grouped. If all rows are null
,
returns null
. The returned column is always typed as
number
. If the col
is not typed as
number
, TypeError will be thrown.The IColumn objects returned by IAggregateFunction MUST NOT be convertible to Column objects.
You cannot use min
or max
on
blob
, boolean
, and object
columns.
Aggregation can only happen on a single table. If the select scope contains more than one table, a SyntaxError will be thrown.
The aggregation can happen for the given table without a
groupBy()
function, which implies grouping on all rows of that
table.
If the groupBy()
is present, the projection list MUST
consist of either the columns in projection list, or aggregation functions.
The parameters of aggregation functions MUST be either undefined
if allowed by signature, or from the columns provided as
groupBy()
parameters.
If grouping and search conditions are both provided, search conditions will be evaluated first, and grouping will be operated on top of the filtered rows.
Aggregation by expression is not supported, i.e. no
CASE WHEN
equivalents. Also, DISTINCT
in SQL is
partially implemented in this specification. You can only use it in the
projection list, NOT in left joins or other cases.
Union projection is different from joins. It just attempts to combine the projected results into rows using the following algorithm:
union()
query first, then in the order of the parameters
inside union()
.let dbConnection; function getOrderList() { let order = dbConnection.schema().table('Order'); let archive = dbConnection.schema().table('ArchivedOrder'); return dbConnection .select(order['item'], order['amount']) .from(order) .where(order['category'].eq('Clothing')) .union(dbConnection .select(archive['item'], archive['amount']) .from(archive) .where(archive.category.eq('Clothing'))) .commit(); }
Intersect and except are different from joins, they just attempt to perform set operations on the projected results from involving queries. The set of projected columns from any two queries involved MUST be identical in name/alias and data type, otherwise a TypeError will be thrown.
Results returned from intersect()
include all the rows that
exist in all involved queries.
Results returned from except()
exists only in the calling
query.
All errors in this specification are DOMException
with the
specified error type.
Field | Description |
---|---|
BlockingError |
Attempts to open a delete pending database, or to create a transaction in a close pending connection. |
BindingError |
The values given in IQuery.bind have type match, or the executing query has unprovided bindable values. |
ConcurrencyError |
In-flight transaction is automatically rolled back because the snapshot it based on has been obsoleted by other committed transactions. |
ConstraintError |
Constraints are violated. |
DataError |
Data given to a column is invalid. For example, assigning
null or undefined to a NOT NULL column,
or assigning values of different column type
. |
IntegrityError |
Query engine is not able to ensure data integrity. For example, failed to rollback a sequence mode transaction. |
InvalidSchemaError |
Provided schema is invalid. |
OutOfMemoryError |
Executing the query demands too much memory than the implementation could provide. Users are responsible for changing their queries to consume less memory/resources. For example, inserting too many rows into a temporary database, or selecting too many rows from a persistent database. |
RuntimeError |
Invalid query has slipped through syntax checks but caught by the query execution engine. |
SyntaxError |
Illegal use of query builder objects, for example, calling
into() twice for IInsertQuery. |
TimeoutError |
Transaction takes too long to resolve (commit) or reject (rollback), and is automatically rolled back. |
TransactionStateError |
Attempts to commit/rollback/attach query to a finished/pending finish transaction. |
TypeError |
Attempts to compare/union two different data types. |
UnsupportedError |
Attempts to use MAY support features that are not supported yet. |
Many thanks to people who have contributed to this specification and helped to improve it by sending suggestions and corrections.
We also want to thank the authors of ReSpec, Node.js, and gulp.js for the powerful and handy tools that are used to produce this spec.