In the specification, the lifecycle of a database is described. In this section, the implementation of this behavior will be detailed.
Lovefield registers an lf.Global.instance_
object in the global namespace (
i.e. window
). This instance is unique and shared across all connections in
the session.
Each connection will register its own global object with this session-unique instance. This global object serves as the service registry for that connection. Since Lovefield assumes one connection to a DB instance on data store at a given session, the global object for that connection will be unique, too.
There are some connection-unique components that are required to support the
query engine. These services are documented in service.js
.
Most services are created and registered during database initialization.
"Connect" in Lovefield means establish association between the library and the
database on data store. It has nothing to do with network connectivity.
Connection is performed via calling either lf.schema.Builder#connect()
, or
SPAC generated <namespace>.connect()
.
The flow of connect
:
- Initialize Global object and register schema.
- Initialize the database
- Create and register row cache and data store object
- Initialize data store object, perform upgrade if needed
- Service initialization
- Prefetch data
IndexedDB requires a name and the version number to open connection to
specified database, which are provided in schema. It will fire the
onupgradeneeded
or onerror
event if it detects version mismatch.
Lovefield provides some helper functions to assist the user perform database
upgrade, which is already documented in the spec.
After the IDBDatabase
object is successfully returned from IndexedDB APIs,
Lovefield will scan every table to identify the maximum row id of a table.
Then, it will gather all the information and determine the next row id to
use for this connection. All the ids are indexed by IndexedDB and theoretically
the scan shall be done in O(N) time where N is the number of tables in schema.
For Firebase initialization, it first will attempt to obtain @db/version
and
@rev/R
for database version and change revision. When there is a version
mismatch, Lovefield will call your onUpgrade
handler, but this time the name
is a bit deceiving. In the case of Firebase, this typically means that the user
is running a cached JS on browser, and what you really want to do is to have
them refresh the session and reload an updated binary.
Object instances of the cache (lf.cache.DefaultCache
), query engine
(lf.proc.DefaultQueryEngine
), transaction runner (lf.proc.Runner
), index
store (lf.index.MemoryStore
), and observer registry (lf.ObserverRegistry
)
will be created during database initialization.
The row cache is conceptually a big map of row ids to rows (and that is why Lovefield has unique row ids across the board). Currently the cache is a "dumb" cache: it contains exact replica of what are persisted in the IndexedDB. The reason for doing that is to workaround IndexedDB inefficiency of handing bulk I/O, as described in backstore section. By caching all rows in memory, Lovefield avoids any additional round-trip required to load data from IndexedDB, with the price of memory usage.
Currently Lovefield has only in-memory index store. The index store initialization will scan all indices specified in the schema and create empty indices accordingly.
The prefetcher (lf.cache.Prefetcher
) is responsible for prefetching data from
data store into the cache. All table data will be loaded into the cache.
The prefetcher loads one table at a time. If the table's schema does not have
the pragma persistentIndex
, then all indices in that table will be constructed
on-the-fly during prefetching, otherwise they will be deserialized from data
store.
By default, Lovefield does not persist index data. This is done so to improve write speed. This configuration is subjected to change once more in-field data is collected.
Lovefield made the design trade-off to have prefetcher perform bulk loading during database initialization, which is not optimal especially for large data sets. In the future, Lovefield plans to implement an MRU-based lazy-load cache that loads data in the background on demand.
For Firebase, the prefetch data will actually trigger Firebase to load data over the wire during the initialization of database. This generally is not a problem since Firebase.js may already had those data. If you had a large amount of data, you will need to fine tune your code and the Firebase server-side settings to overcome this issue.
Once the database is fully initialized, it can start accepting queries. The life of query consists of three stages:
- Build query context
- (Optional) Bind values to parametrized query
- Create query plan
- Execute query plan
Query context is built from one of the query builders (lf.query.*
). The query
builders inherit common base class lf.query.BaseBuilder
and implement one of
the query builder interfaces (lf.query.Select/Insert/Update/Delete
). The query
builders perform the following major tasks:
- Create the query context
- Validate input and syntax
- Bind values for parametrized queries
Once the query context is successfully built, the builder can be used to
generate the query plan through its exec()
or explain()
method.
Parameterized query works similarily to Oracle's or SQLite's parametrized query API. The general idea is to put a placeholder in query context, and replace the value with runtime values (i.e. bind the parameters).
There are two different scenarios in parametrized query:
- Search condition
- Update set
The search condition binding is achieved via value predicates.
The lf.bind
will return an lf.Binder
object. When value predicate is
constructed with lf.Binder
(for most operators) or array of lf.Binder
(in
the case of IN
or BETWEEN
), it will keep the binder reference internally.
When bind
method is called, it will update its internally stored value
to
the value(s) given in bind
. When the eval
method is called, the predicate
will return the bound value, or throws an error if unbound.
The update set binding is done in update_builder.js
,
since all the set values are kept internally in that class.
This is the main task of the Query Engine and is documented separately.
The query plans are executed withing a transaction context, which is documented separately in Transaction.