├── .npmignore
├── .babelrc
├── docs
├── image
│ ├── github.png
│ ├── search.png
│ └── badge.svg
├── script
│ ├── patch-for-local.js
│ ├── manual.js
│ ├── pretty-print.js
│ ├── inherited-summary.js
│ ├── inner-link.js
│ ├── test-summary.js
│ └── search.js
├── badge.svg
├── package.json
├── css
│ └── prettify-tomorrow.css
├── manual
│ ├── changelog.html
│ └── index.html
├── coverage.json
└── file
│ └── lib
│ ├── attributes
│ └── attribute-boolean.js.html
│ ├── json-blob.js.html
│ └── utils.js.html
├── example
├── .babelrc
├── screenshot.png
├── img
│ ├── wikipedia.ico
│ ├── add.svg
│ ├── popout.svg
│ ├── favorite.svg
│ ├── favorite-true.svg
│ ├── delete.svg
│ └── settings.svg
├── .npmrc
├── renderer.html
├── src
│ ├── importers
│ │ ├── folder.js
│ │ └── wikipedia.js
│ ├── models
│ │ └── note.js
│ └── components
│ │ ├── wikipedia-button.jsx
│ │ ├── sidebar-recents.jsx
│ │ ├── sidebar-item.jsx
│ │ ├── sidebar-search-results.jsx
│ │ ├── container.jsx
│ │ └── detail.jsx
├── package.json
├── initial-notes
│ ├── 2. Inca Tern.html
│ ├── 1. Welcome to Notes.html
│ ├── 3. Intro to React.html
│ └── 4. Nintendo 64.html
├── renderer.js
├── index.js
└── renderer.css
├── src
├── search-indexes
│ ├── index.js
│ └── search-index-fts5.js
├── attributes
│ ├── attribute-boolean.js
│ ├── sort-order.js
│ ├── attribute-string.js
│ ├── attribute-object.js
│ ├── index.js
│ ├── attribute-number.js
│ ├── attribute-datetime.js
│ ├── attribute-joined-data.js
│ ├── attribute.js
│ ├── attribute-collection.js
│ └── matcher.js
├── json-blob.js
├── index.js
├── utils.js
├── mutable-query-subscription.js
├── console-utils.js
├── database-change-record-debouncer.js
├── database-change-record.js
├── query-range.js
├── browser
│ └── coordinator.js
├── query-builder.js
├── query-subscription-pool.js
├── model.js
├── mutable-query-result-set.js
├── model-registry.js
└── query-result-set.js
├── spec
├── support
│ └── jasmine.json
├── runner
│ ├── index.html
│ ├── index.js
│ └── renderer.js
├── fixtures
│ ├── category.js
│ ├── index.js
│ ├── message.js
│ ├── test-model.js
│ └── thread.js
├── model-registry-spec.js
├── query-subscription-pool-spec.js
├── database-setup-query-builder-spec.js
├── query-range-spec.js
└── mutable-query-result-set-spec.js
├── .gitignore
├── CHANGELOG.md
├── .travis.yml
├── esdoc.json
├── .eslintrc.json
├── package.json
├── post-install.js
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /example
3 | /node_modules
4 | /docs/ast
5 | /src
6 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["electron", "react"],
3 | "sourceMaps": "inline"
4 | }
5 |
--------------------------------------------------------------------------------
/docs/image/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nylas/electron-RxDB/HEAD/docs/image/github.png
--------------------------------------------------------------------------------
/docs/image/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nylas/electron-RxDB/HEAD/docs/image/search.png
--------------------------------------------------------------------------------
/example/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["electron", "react"],
3 | "sourceMaps": "inline"
4 | }
5 |
--------------------------------------------------------------------------------
/example/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nylas/electron-RxDB/HEAD/example/screenshot.png
--------------------------------------------------------------------------------
/example/img/wikipedia.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nylas/electron-RxDB/HEAD/example/img/wikipedia.ico
--------------------------------------------------------------------------------
/example/.npmrc:
--------------------------------------------------------------------------------
1 | npm_config_target='1.4.1'
2 | npm_config_disturl='https://atom.io/download/electron'
3 | npm_config_runtime=electron
4 | npm_config_build_from_source=true
5 |
--------------------------------------------------------------------------------
/src/search-indexes/index.js:
--------------------------------------------------------------------------------
1 | import SearchIndexFTS5 from './search-index-fts5'
2 |
3 | module.exports = {
4 | FTS5: (...args) => new SearchIndexFTS5(...args),
5 |
6 | SearchIndexFTS5: SearchIndexFTS5,
7 | };
8 |
--------------------------------------------------------------------------------
/spec/support/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": "spec",
3 | "spec_files": [
4 | "**/*[sS]pec.js"
5 | ],
6 | "helpers": [
7 | "helpers/**/*.js"
8 | ],
9 | "stopSpecOnExpectationFailure": false,
10 | "random": false
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /example/node_modules
3 | /node_modules
4 | /example/sqlite.db-wal
5 | /example/sqlite.db-shm
6 | /sqlite-test.db-wal
7 | /sqlite-test.db-shm
8 | /sqlite-test.db
9 | /npm-debug.log
10 | /docs/ast
11 | /example/npm-debug.log
12 | /lib
13 |
--------------------------------------------------------------------------------
/docs/script/patch-for-local.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | if (location.protocol === 'file:') {
3 | var elms = document.querySelectorAll('a[href="./"]');
4 | for (var i = 0; i < elms.length; i++) {
5 | elms[i].href = './index.html';
6 | }
7 | }
8 | })();
9 |
--------------------------------------------------------------------------------
/example/renderer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Notes
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/spec/runner/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Spec Runner
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Changelog
2 |
3 | - 0.1.0 - Oct 17, 2016
4 | + Core components extracted from Nylas N1, converted from CoffeeScript to ES2016
5 | + Specs moved from Jasmine 1.3 to Jasmine 2.5, refactored to run without
6 | N1 classes and fixtures.
7 | + New "Notes" example application demonstrates use in a minimal Electron app
8 |
--------------------------------------------------------------------------------
/spec/runner/index.js:
--------------------------------------------------------------------------------
1 | import {app, BrowserWindow} from 'electron';
2 | import Coordinator from '../../src/browser/coordinator';
3 |
4 | global.databaseCoordinator = new Coordinator();
5 |
6 | function createMainWindow() {
7 | const win = new BrowserWindow({
8 | width: 600,
9 | height: 400,
10 | show: false,
11 | });
12 |
13 | win.loadURL(`file://${__dirname}/index.html`);
14 | win.once('ready-to-show', () => {
15 | win.show();
16 | })
17 | }
18 |
19 | app.on('ready', () => {
20 | createMainWindow();
21 | });
22 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 6
4 |
5 | addons:
6 | apt:
7 | sources:
8 | - ubuntu-toolchain-r-test
9 | packages:
10 | - build-essential
11 | - clang
12 | - fakeroot
13 | - g++-4.8
14 | - git
15 | - libgnome-keyring-dev
16 | - xvfb
17 | - rpm
18 |
19 | env:
20 | - CC=gcc-4.8 CXX=g++-4.8
21 |
22 | before_script:
23 | - if [ "${TRAVIS_OS_NAME}" == "linux" ]; then
24 | export DISPLAY=:99.0;
25 | sh -e /etc/init.d/xvfb start;
26 | fi
27 |
28 | script:
29 | - npm run lint
30 | - npm test
31 |
--------------------------------------------------------------------------------
/src/attributes/attribute-boolean.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 |
3 | /**
4 | The value of this attribute is always a boolean. Null values are coerced to false.
5 |
6 | String attributes can be queries using `equal` and `not`. Matching on
7 | `greaterThan` and `lessThan` is not supported.
8 | */
9 | export default class AttributeBoolean extends Attribute {
10 | toJSON(val) {
11 | return val;
12 | }
13 | fromJSON(val) {
14 | return ((val === 'true') || (val === true)) || false;
15 | }
16 | columnSQL() {
17 | return `${this.jsonKey} INTEGER`;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/example/src/importers/folder.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 | import Note from '../models/note';
4 |
5 | export default class FolderImporter {
6 | run(dir) {
7 | const notes = [];
8 | for (const filename of fs.readdirSync(dir)) {
9 | if (filename.endsWith('.html')) {
10 | notes.push(new Note({
11 | name: filename.replace('.html', ''),
12 | content: fs.readFileSync(path.join(dir, filename)),
13 | }));
14 | }
15 | }
16 | window.Database.inTransaction((t) => {
17 | return t.persistModels(notes);
18 | })
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/docs/script/manual.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | var matched = location.pathname.match(/([^/]*)\.html$/);
3 | if (!matched) return;
4 |
5 | var currentName = matched[1];
6 | var cssClass = '.navigation [data-toc-name="' + currentName + '"]';
7 | var styleText = cssClass + ' .manual-toc { display: block; }\n';
8 | styleText += cssClass + ' .manual-toc-title { background-color: #039BE5; }\n';
9 | styleText += cssClass + ' .manual-toc-title a { color: white; }\n';
10 | var style = document.createElement('style');
11 | style.textContent = styleText;
12 | document.querySelector('head').appendChild(style);
13 | })();
14 |
--------------------------------------------------------------------------------
/esdoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "source": "./lib",
3 | "destination": "./docs",
4 | "plugins": [
5 | {"name": "esdoc-es7-plugin"}
6 | ],
7 | "access": ["public", "protected"],
8 | "autoPrivate": true,
9 | "unexportIdentifier": false,
10 | "undocumentIdentifier": true,
11 | "builtinExternal": true,
12 | "title": "electron-RxDB",
13 | "coverage": true,
14 | "index": "./README.md",
15 | "package": "./package.json",
16 | "test": {
17 | "type": "mocha",
18 | "source": "./spec"
19 | },
20 | "manual": {
21 | "overview": ["./manual/database.md"],
22 | "changelog": ["./CHANGELOG.md"]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/example/img/add.svg:
--------------------------------------------------------------------------------
1 | add Created with Sketch.
--------------------------------------------------------------------------------
/spec/fixtures/category.js:
--------------------------------------------------------------------------------
1 | import Attributes from '../../src/attributes'
2 | import Model from '../../src/model'
3 |
4 | export default class Category extends Model {
5 | static attributes = Object.assign({}, Model.attributes, {
6 | accountId: Attributes.String({
7 | modelKey: 'accountId',
8 | jsonKey: 'account_id',
9 | queryable: true,
10 | }),
11 | name: Attributes.String({
12 | queryable: true,
13 | modelKey: 'name',
14 | }),
15 | displayName: Attributes.String({
16 | queryable: true,
17 | modelKey: 'displayName',
18 | jsonKey: 'display_name',
19 | }),
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/spec/fixtures/index.js:
--------------------------------------------------------------------------------
1 | import RxDatabase from '../../src/rx-database';
2 | import Thread from './thread';
3 | import Message from './message';
4 | import TestModel from './test-model';
5 | import Category from './category';
6 |
7 | const Database = new RxDatabase({
8 | primary: true,
9 | databasePath: 'sqlite-test.db',
10 | databaseVersion: "1",
11 | logQueries: false,
12 | logQueryPlans: false,
13 | });
14 |
15 | Database._openDatabase = () =>
16 |
17 | Database.models.register(Thread)
18 | Database.models.register(Message)
19 | Database.models.register(Category)
20 | Database.models.register(TestModel)
21 |
22 | module.exports = {
23 | Database,
24 | Thread,
25 | Message,
26 | Category,
27 | TestModel,
28 | }
29 |
--------------------------------------------------------------------------------
/src/json-blob.js:
--------------------------------------------------------------------------------
1 | import Model from './model';
2 | import Query from './query';
3 | import Attributes from './attributes';
4 |
5 | class JSONBlobQuery extends Query {
6 | formatResult(objects) {
7 | return objects[0] ? objects[0].json : null;
8 | }
9 | }
10 |
11 | export default class JSONBlob extends Model {
12 | static Query = JSONBlobQuery;
13 |
14 | static attributes = {
15 | id: Attributes.String({
16 | queryable: true,
17 | modelKey: 'id',
18 | }),
19 |
20 | json: Attributes.Object({
21 | modelKey: 'json',
22 | jsonKey: 'json',
23 | }),
24 | };
25 |
26 | get key() {
27 | return this.id;
28 | }
29 |
30 | set key(val) {
31 | this.id = val;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /* eslint global-require:0 */
2 |
3 | if (process.type === 'renderer') {
4 | module.exports = {
5 | RxDatabase: require('./rx-database').default,
6 | Model: require('./model').default,
7 | ModelRegistry: require('./model-registry').default,
8 |
9 | // Just making the public API more consistent with Attributes in case there
10 | // are other types of search indexes in the future.
11 | Attributes: require('./attributes'),
12 | SearchIndexes: require('./search-indexes'),
13 | };
14 | } else if (process.type === 'browser') {
15 | module.exports = () => {
16 | const Coordinator = require('./browser/coordinator').default;
17 |
18 | global._rxdb = {
19 | coordinator: new Coordinator(),
20 | };
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export function modelFreeze(o) {
2 | Object.freeze(o);
3 | return Object.getOwnPropertyNames(o).forEach((key) => {
4 | const val = o[key];
5 | if (typeof val === 'object' && val !== null && !Object.isFrozen(val)) {
6 | modelFreeze(val);
7 | }
8 | });
9 | }
10 |
11 | export function generateTempId() {
12 | const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
13 | return `local-${s4()}${s4()}-${s4()}`;
14 | }
15 |
16 | export function isTempId(id) {
17 | if (!id || typeof id !== 'string') { return false; }
18 | return id.slice(0, 6) === 'local-';
19 | }
20 |
21 | export function tableNameForJoin(primaryKlass, secondaryKlass) {
22 | return `${primaryKlass.name}${secondaryKlass.name}`;
23 | }
24 |
--------------------------------------------------------------------------------
/src/attributes/sort-order.js:
--------------------------------------------------------------------------------
1 | /**
2 | Represents a particular sort direction on a particular column. You should not
3 | instantiate SortOrders manually. Instead, call {@link Attribute.ascending} or
4 | {@link Attribute.descending} to obtain a sort order instance:
5 |
6 | ```js
7 | db.findBy(Message)
8 | .where({threadId: threadId, draft: false})
9 | .order(Message.attributes.date.descending()).then((messages) {
10 |
11 | });
12 | ```
13 |
14 | Section: Database
15 | */
16 | export default class SortOrder {
17 | constructor(attr, direction = 'DESC') {
18 | this.attr = attr;
19 | this.direction = direction;
20 | }
21 |
22 | orderBySQL(klass) {
23 | return `\`${klass.name}\`.\`${this.attr.jsonKey}\` ${this.direction}`;
24 | }
25 |
26 | attribute() {
27 | return this.attr;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/example/img/popout.svg:
--------------------------------------------------------------------------------
1 | share Created with Sketch.
--------------------------------------------------------------------------------
/docs/badge.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | document
13 | document
14 | 36%
15 | 36%
16 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/image/badge.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | document
13 | document
14 | @ratio@
15 | @ratio@
16 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/script/pretty-print.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | prettyPrint();
3 | var lines = document.querySelectorAll('.prettyprint.linenums li[class^="L"]');
4 | for (var i = 0; i < lines.length; i++) {
5 | lines[i].id = 'lineNumber' + (i + 1);
6 | }
7 |
8 | var matched = location.hash.match(/errorLines=([\d,]+)/);
9 | if (matched) {
10 | var lines = matched[1].split(',');
11 | for (var i = 0; i < lines.length; i++) {
12 | var id = '#lineNumber' + lines[i];
13 | var el = document.querySelector(id);
14 | el.classList.add('error-line');
15 | }
16 | return;
17 | }
18 |
19 | if (location.hash) {
20 | // ``[ ] . ' " @`` are not valid in DOM id. so must escape these.
21 | var id = location.hash.replace(/([\[\].'"@$])/g, '\\$1');
22 | var line = document.querySelector(id);
23 | if (line) line.classList.add('active');
24 | }
25 | })();
26 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "standard",
4 | "plugins": [
5 | "react",
6 | "standard",
7 | "promise",
8 | "import",
9 | "jsx-a11y"
10 | ],
11 | "parserOptions": {
12 | "ecmaFeatures": {
13 | "jsx": true
14 | }
15 | },
16 | "env": {
17 | "browser": true,
18 | "node": true,
19 | "jasmine": true
20 | },
21 | "rules": {
22 | "semi": ["off"],
23 | "react/jsx-uses-vars": [2],
24 | "space-before-function-paren": ["off"],
25 | "no-multiple-empty-lines": ["off"],
26 | "quotes": ["off"],
27 | "comma-dangle": ["error", {
28 | "arrays": "always-multiline",
29 | "objects": "always-multiline",
30 | "imports": "always-multiline",
31 | "exports": "always-multiline",
32 | "functions": "ignore"
33 | }]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/docs/script/inherited-summary.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | function toggle(ev) {
3 | var button = ev.target;
4 | var parent = ev.target.parentElement;
5 | while(parent) {
6 | if (parent.tagName === 'TABLE' && parent.classList.contains('summary')) break;
7 | parent = parent.parentElement;
8 | }
9 |
10 | if (!parent) return;
11 |
12 | var tbody = parent.querySelector('tbody');
13 | if (button.classList.contains('opened')) {
14 | button.classList.remove('opened');
15 | button.classList.add('closed');
16 | tbody.style.display = 'none';
17 | } else {
18 | button.classList.remove('closed');
19 | button.classList.add('opened');
20 | tbody.style.display = 'block';
21 | }
22 | }
23 |
24 | var buttons = document.querySelectorAll('.inherited-summary thead .toggle');
25 | for (var i = 0; i < buttons.length; i++) {
26 | buttons[i].addEventListener('click', toggle);
27 | }
28 | })();
29 |
--------------------------------------------------------------------------------
/src/attributes/attribute-string.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 | import Matcher from './matcher';
3 |
4 | /**
5 | The value of this attribute is always a string or `null`.
6 |
7 | String attributes can be queries using `equal`, `not`, and `startsWith`.
8 | Matching on `greaterThan` and `lessThan` is not supported.
9 | */
10 | export default class AttributeString extends Attribute {
11 | toJSON(val) {
12 | return val;
13 | }
14 |
15 | fromJSON(val) {
16 | return (val === null || val === undefined || val === false) ? null : `${val}`;
17 | }
18 |
19 | // Public: Returns a {Matcher} for objects starting with the provided value.
20 | startsWith(val) {
21 | return new Matcher(this, 'startsWith', val);
22 | }
23 |
24 | columnSQL() {
25 | return `${this.jsonKey} TEXT`;
26 | }
27 |
28 | like(val) {
29 | this._assertPresentAndQueryable('like', val);
30 | return new Matcher(this, 'like', val);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example/img/favorite.svg:
--------------------------------------------------------------------------------
1 | like Created with Sketch.
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "app",
3 | "productName": "App",
4 | "version": "0.0.0",
5 | "description": "",
6 | "license": "MIT",
7 | "repository": "user/repo",
8 | "author": {
9 | "name": "",
10 | "email": "",
11 | "url": ""
12 | },
13 | "scripts": {
14 | "start": "electron . --enable-logging",
15 | "build": "electron-packager . --out=dist --asar --overwrite --all",
16 | "postinstall": "rm -f ./node_modules/electron-rxdb; ln -s ../../ ./node_modules/electron-rxdb"
17 | },
18 | "files": [
19 | "index.js"
20 | ],
21 | "keywords": [
22 | "electron-app",
23 | "electron"
24 | ],
25 | "dependencies": {
26 | "babel-preset-electron": "^0.37.8",
27 | "babel-preset-es2016-node5": "^1.1.2",
28 | "babel-preset-react": "^6.16.0",
29 | "electron-debug": "^1.1.0",
30 | "react": "^15.3.2",
31 | "react-dom": "^15.3.2",
32 | "request": "^2.79.0"
33 | },
34 | "devDependencies": {
35 | "electron-prebuilt-compile": "1.4.7"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/spec/runner/renderer.js:
--------------------------------------------------------------------------------
1 | const app = require('electron').remote.app;
2 | const Jasmine = require('jasmine');
3 |
4 | const jasmineInstance = new Jasmine();
5 | jasmineInstance.loadConfigFile('spec/support/jasmine.json');
6 | jasmineInstance.onComplete(() => {
7 | app.quit();
8 | });
9 |
10 | // jasmine.configureDefaultReporter({
11 | // timer: new jasmine.Timer(),
12 | // print: (...args) => {
13 | // process.stdout.write(util.format.apply(this, args));
14 | // },
15 | // showColors: true,
16 | // jasmineCorePath: jasmineCorePath
17 | // });
18 |
19 | global.jasmine = jasmineInstance.jasmine;
20 | global.jasmine.waitFor = (fn) => (
21 | new Promise((resolve, reject) => {
22 | const start = Date.now()
23 | const tick = () => {
24 | if (Date.now() - start > 1000) {
25 | return reject(new Error(`jasmine.waitFor timed out. ${fn}`));
26 | }
27 | return (fn() ? resolve() : process.nextTick(tick))
28 | }
29 | tick();
30 | })
31 | )
32 |
33 | jasmineInstance.execute();
34 |
--------------------------------------------------------------------------------
/example/initial-notes/2. Inca Tern.html:
--------------------------------------------------------------------------------
1 |
2 | The Inca tern (Larosterna inca) is a seabird in the family Sternidae. It is the only member of the genus Larosterna.
3 |
4 |
5 | This uniquely plumaged bird breeds on the coasts of Peru and Chile, and is restricted to the Humboldt Current. It is an erratic, rare visitor to the southwest coast of Ecuador. It can be identified by its dark grey body, white moustache on both sides of its head, and red-orange beak and feet.
6 |
7 |
8 | The Inca tern is a large tern, approximately 40 cm long. Sexes are similar; the adult is mostly slate-grey with white restricted to the facial plumes and the trailing edges of the wings. The large bill and legs are dark red. Immature birds are purple-brown, and gradually develop the facial plumes.
9 |
10 |
11 | The Inca tern breeds on rocky cliffs. It nests in a hollow or burrow or sometimes the old nest of a Humboldt penguin, and lays one or two eggs. The eggs are incubated for about 4 weeks, and the chicks leave the nest after 7 weeks.[2]
12 |
13 |
--------------------------------------------------------------------------------
/src/mutable-query-subscription.js:
--------------------------------------------------------------------------------
1 | import QuerySubscription from './query-subscription'
2 |
3 | class MutableQuerySubscription extends QuerySubscription {
4 |
5 | replaceQuery(nextQuery) {
6 | if (this._query && this._query.sql() === nextQuery.sql()) {
7 | return
8 | }
9 |
10 | let rangeIsOnlyChange = false;
11 | if (this._query) {
12 | rangeIsOnlyChange = this._query.clone().offset(0).limit(0).sql() === nextQuery.clone().offset(0).limit(0).sql()
13 | }
14 |
15 | this.cancelPendingUpdate()
16 |
17 | nextQuery.finalize()
18 | this._query = nextQuery
19 | if (!(this._set && rangeIsOnlyChange)) {
20 | this._set = null
21 | }
22 | this.update()
23 | }
24 |
25 | replaceRange = ({start, end}) => {
26 | if (!this._query) {
27 | return
28 | }
29 |
30 | const next = this._query.clone().page(start, end);
31 | if (!next.range().isEqual(this._query.range())) {
32 | this.replaceQuery(next);
33 | }
34 | }
35 | }
36 |
37 | export default MutableQuerySubscription
38 |
--------------------------------------------------------------------------------
/src/console-utils.js:
--------------------------------------------------------------------------------
1 | /* eslint import/prefer-default-export: 0 */
2 | export function logSQLString(qa) {
3 | let q = qa.replace(/%/g, '%%');
4 | q = `color:black |||%c ${q}`;
5 | q = q.replace(/`(\w+)`/g, "||| color:purple |||%c$&||| color:black |||%c");
6 |
7 | const colorRules = {
8 | 'color:green': ['SELECT', 'INSERT INTO', 'VALUES', 'WHERE', 'FROM', 'JOIN', 'ORDER BY', 'DESC', 'ASC', 'INNER', 'OUTER', 'LIMIT', 'OFFSET', 'IN'],
9 | 'color:red; background-color:#ffdddd;': ['SCAN TABLE'],
10 | };
11 |
12 | for (const style of Object.keys(colorRules)) {
13 | for (const keyword of colorRules[style]) {
14 | q = q.replace(new RegExp(`\\b${keyword}\\b`, 'g'), `||| ${style} |||%c${keyword}||| color:black |||%c`);
15 | }
16 | }
17 |
18 | q = q.split('|||');
19 | const colors = [];
20 | const msg = [];
21 | for (let i = 0; i < q.length; i++) {
22 | if (i % 2 === 0) {
23 | colors.push(q[i]);
24 | } else {
25 | msg.push(q[i]);
26 | }
27 | }
28 |
29 | console.log(msg.join(''), ...colors);
30 | }
31 |
--------------------------------------------------------------------------------
/docs/script/inner-link.js:
--------------------------------------------------------------------------------
1 | // inner link(#foo) can not correctly scroll, because page has fixed header,
2 | // so, I manually scroll.
3 | (function(){
4 | var matched = location.hash.match(/errorLines=([\d,]+)/);
5 | if (matched) return;
6 |
7 | function adjust() {
8 | window.scrollBy(0, -55);
9 | var el = document.querySelector('.inner-link-active');
10 | if (el) el.classList.remove('inner-link-active');
11 |
12 | // ``[ ] . ' " @`` are not valid in DOM id. so must escape these.
13 | var id = location.hash.replace(/([\[\].'"@$])/g, '\\$1');
14 | var el = document.querySelector(id);
15 | if (el) el.classList.add('inner-link-active');
16 | }
17 |
18 | window.addEventListener('hashchange', adjust);
19 |
20 | if (location.hash) {
21 | setTimeout(adjust, 0);
22 | }
23 | })();
24 |
25 | (function(){
26 | var els = document.querySelectorAll('[href^="#"]');
27 | for (var i = 0; i < els.length; i++) {
28 | var el = els[i];
29 | el.href = location.href + el.getAttribute('href'); // because el.href is absolute path
30 | }
31 | })();
32 |
--------------------------------------------------------------------------------
/src/attributes/attribute-object.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 |
3 | /**
4 | The value of this attribute is always an object that can be cast to `itemClass`
5 | */
6 | export default class AttributeObject extends Attribute {
7 | constructor({modelKey, jsonKey, itemClass, queryable}) {
8 | super({modelKey, jsonKey, queryable});
9 | this.ItemClass = itemClass;
10 | }
11 |
12 | toJSON(val) {
13 | return (val && val.toJSON) ? val.toJSON() : val;
14 | }
15 |
16 | fromJSON(val) {
17 | if (!this.ItemClass) {
18 | return val || "";
19 | }
20 | const obj = new this.ItemClass(val);
21 |
22 | // Important: if no ids are in the JSON, don't make them up randomly.
23 | // This causes an object to be "different" each time it's de-serialized
24 | // even if it's actually the same, makes React components re-render!
25 | obj.id = undefined;
26 |
27 | // Warning: typeof(null) is object
28 | if (obj.fromJSON && !!val && (typeof val === 'object')) {
29 | obj.fromJSON(val);
30 | }
31 |
32 | return obj;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/example/img/favorite-true.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Slice 1
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/example/src/models/note.js:
--------------------------------------------------------------------------------
1 | import {Model, Attributes, SearchIndexes} from 'electron-rxdb';
2 |
3 | const contentdiv = document.createElement('div');
4 |
5 | export default class Note extends Model {
6 | static attributes = Object.assign(Model.attributes, {
7 | name: Attributes.String({
8 | modelKey: 'name',
9 | jsonKey: 'name',
10 | queryable: true,
11 | }),
12 | starred: Attributes.Boolean({
13 | modelKey: 'starred',
14 | jsonKey: 'starred',
15 | queryable: true,
16 | }),
17 | content: Attributes.String({
18 | modelKey: 'content',
19 | jsonKey: 'content',
20 | }),
21 | updatedAt: Attributes.DateTime({
22 | modelKey: 'updatedAt',
23 | jsonKey: 'updatedAt',
24 | queryable: true,
25 | }),
26 | });
27 |
28 | static searchIndexes = {
29 | titleAndContents: SearchIndexes.FTS5({
30 | version: 1,
31 | getDataForModel: (model) => {
32 | // the content is HTML, and the indexer needs words separated by spaces.
33 | contentdiv.innerHTML = model.content;
34 | return model.name + contentdiv.innerText;
35 | },
36 | }),
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/example/src/components/wikipedia-button.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import WikipediaImporter from '../importers/wikipedia';
3 |
4 | export default class WikipediaButton extends React.Component {
5 | constructor(props) {
6 | super(props);
7 |
8 | this.state = {
9 | running: false,
10 | count: 0,
11 | };
12 |
13 | this.importer = new WikipediaImporter();
14 | this.importer.on('updated', this._onImporterUpdate);
15 | }
16 |
17 | componentWillUnmount() {
18 | this.importer.removeEventListener('updated', this._onImporterUpdate);
19 | this.importer.cancel();
20 | }
21 |
22 | _onImporterUpdate = () => {
23 | this.setState({
24 | count: this.importer.count,
25 | running: this.importer.running,
26 | });
27 | }
28 |
29 | render() {
30 | if (this.state.running) {
31 | return (
32 | this.importer.cancel()}>
33 | {this.state.count}
34 |
35 | );
36 | }
37 |
38 | return (
39 | this.importer.run()}>
40 | W
41 |
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/example/initial-notes/1. Welcome to Notes.html:
--------------------------------------------------------------------------------
1 |
2 | This is a small Notes app built on Electron-RxDB .
3 | A few notes have been added already - you can update a note by typing here,
4 | or create a new note in the bottom right. As you make changes, notes are saved
5 | back to SQLite and the interface updates automatically. This is possible because
6 | React components in the UI are bound to RxDB observable queries.
7 |
8 |
9 | Check out sidebar-recents.jsx for an example of an observable query.
10 |
11 |
12 | In this demo, you can also search notes using SQLite full-text search (FTS5). It's hard to
13 | demonstrate on a small data set, but SQLite FTS5 remains performant on hundreds
14 | of thousands of rows. To make things more fun, click the "Wikipedia" button below.
15 | The demo will crawl Wikipedia and pull down articles until you click the button again.
16 |
17 |
18 | Want to check out the SQLite data manually? This sample app writes to
19 | ~/Library/Application Support/Notes/sqlite.db on Mac OS X.
20 |
21 |
--------------------------------------------------------------------------------
/example/initial-notes/3. Intro to React.html:
--------------------------------------------------------------------------------
1 |
2 | React is a declarative, efficient, and flexible JavaScript library for building user interfaces.
3 |
4 |
5 | React has a few different kinds of components, but we'll start with React.Component subclasses:
6 |
7 |
8 |
9 | class ShoppingList extends React.Component {
10 | render() {
11 | return (
12 | <div className="shopping-list">
13 | <h1>Shopping List for {this.props.name}</h1>
14 | <ul>
15 | <li>Instagram</li>
16 | <li>WhatsApp</li>
17 | <li>Oculus</li>
18 | </ul>
19 | </div>
20 | );
21 | }
22 | }
23 |
24 | // Example usage: <ShoppingList name="Mark" />
25 |
26 |
27 |
28 | We'll get to the funny XML-like tags in a second. Your components tell React what you want to render – then React will efficiently update and render just the right components when your data changes.
29 |
30 |
31 | Here, ShoppingList is a React component class, or React component type. A component takes in parameters, called props, and returns a hierarchy of views to display via the render method.
32 |
33 |
--------------------------------------------------------------------------------
/example/src/components/sidebar-recents.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SidebarItem from '../components/sidebar-item';
4 | import Note from '../models/note';
5 |
6 | export default class SidebarRecents extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {
10 | items: [],
11 | };
12 | }
13 |
14 | componentDidMount() {
15 | const query = window.Database
16 | .findAll(Note)
17 | .order(Note.attributes.updatedAt.descending())
18 | .limit(2);
19 |
20 | this._observable = query.observe().subscribe((items) => {
21 | this.setState({items});
22 | });
23 | }
24 |
25 | componentWillUnmount() {
26 | this._observable.dispose();
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
Recent Notes
33 | {
34 | this.state.items.map((item) => {
35 | return (
36 |
this.props.onSelectItem(item)}
40 | />
41 | );
42 | })
43 | }
44 |
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "electron-coresqlite",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "dependencies": {
7 | "better-sqlite3": "git+https://github.com/bengotow/better-sqlite3.git#a888061ad334c76d2db4c06554c90785cc6e7cce",
8 | "lru-cache": "^4.0.1",
9 | "node-gyp": "^3.4.0",
10 | "promise-queue": "^2.2.3",
11 | "promise.prototype.finally": "^2.0.1",
12 | "promise.try": "^1.0.0",
13 | "rx-lite": "^4.0.8"
14 | },
15 | "devDependencies": {
16 | "babel-eslint": "^7.0.0",
17 | "babel-preset-electron": "^0.37.8",
18 | "babel-preset-es2016-node5": "^1.1.2",
19 | "babel-preset-react": "^6.16.0",
20 | "electron-prebuilt-compile": "^1.4.4",
21 | "esdoc-es7-plugin": "0.0.3",
22 | "eslint": "^3.8.0",
23 | "eslint-config-airbnb": "^12.0.0",
24 | "eslint-plugin-import": "^1.16.0",
25 | "eslint-plugin-jsx-a11y": "^2.2.3",
26 | "eslint-plugin-react": "^6.4.1",
27 | "jasmine": "^2.5.2"
28 | },
29 | "scripts": {
30 | "test": "CI=true electron ./spec/runner --enable-logging",
31 | "lint": "./node_modules/.bin/eslint ./lib/ ./spec/",
32 | "docs": "esdoc -c esdoc.json",
33 | "postinstall": "node ./post-install.js"
34 | },
35 | "author": "",
36 | "license": "ISC"
37 | }
38 |
--------------------------------------------------------------------------------
/src/attributes/index.js:
--------------------------------------------------------------------------------
1 | import Matcher from './matcher'
2 | import SortOrder from './sort-order'
3 | import AttributeNumber from './attribute-number'
4 | import AttributeString from './attribute-string'
5 | import AttributeObject from './attribute-object'
6 | import AttributeBoolean from './attribute-boolean'
7 | import AttributeDateTime from './attribute-datetime'
8 | import AttributeCollection from './attribute-collection'
9 | import AttributeJoinedData from './attribute-joined-data'
10 |
11 | module.exports = {
12 | Matcher: Matcher,
13 | SortOrder: SortOrder,
14 |
15 | Number: (...args) => new AttributeNumber(...args),
16 | String: (...args) => new AttributeString(...args),
17 | Object: (...args) => new AttributeObject(...args),
18 | Boolean: (...args) => new AttributeBoolean(...args),
19 | DateTime: (...args) => new AttributeDateTime(...args),
20 | Collection: (...args) => new AttributeCollection(...args),
21 | JoinedData: (...args) => new AttributeJoinedData(...args),
22 |
23 | AttributeNumber: AttributeNumber,
24 | AttributeString: AttributeString,
25 | AttributeObject: AttributeObject,
26 | AttributeBoolean: AttributeBoolean,
27 | AttributeDateTime: AttributeDateTime,
28 | AttributeCollection: AttributeCollection,
29 | AttributeJoinedData: AttributeJoinedData,
30 | };
31 |
--------------------------------------------------------------------------------
/example/renderer.js:
--------------------------------------------------------------------------------
1 | import {RxDatabase} from 'electron-rxdb';
2 | import {remote} from 'electron';
3 | import path from 'path';
4 | import React from 'react';
5 | import ReactDOM from 'react-dom';
6 |
7 | import Note from './src/models/note';
8 | import Container from './src/components/container';
9 | import FolderImporter from './src/importers/folder';
10 |
11 | const Database = new RxDatabase({
12 | primary: true,
13 | databasePath: path.join(remote.getGlobal('dataDirectory'), 'sqlite.db'),
14 | databaseVersion: "1",
15 | logQueries: false,
16 | logQueryPlans: false,
17 | });
18 |
19 | Database.on('will-rebuild-database', ({error}) => {
20 | remote.dialog.showErrorBox("A critical database error has occurred.", error.stack);
21 | });
22 |
23 | Database.models.register(Note);
24 |
25 | // If there are no notes, add a few samples from the "initial notes" folder.
26 | // Makes this demo a lot less boring...
27 | Database.count(Note).limit(1).then((count) => {
28 | if (count === 0) {
29 | (new FolderImporter()).run(path.join(__dirname, 'initial-notes'));
30 | }
31 | })
32 |
33 | window.Database = Database;
34 |
35 | document.addEventListener('DOMContentLoaded', () => {
36 | const container = document.getElementById('container');
37 | ReactDOM.render(React.createElement(Container), container);
38 | });
39 |
--------------------------------------------------------------------------------
/example/index.js:
--------------------------------------------------------------------------------
1 | const {app, BrowserWindow} = require('electron');
2 | const fs = require('fs');
3 | const path = require('path');
4 |
5 | // adds debug features like hotkeys for triggering dev tools and reload
6 | require('electron-debug')();
7 |
8 | // adds RxDB coordinator to the main process
9 | require('electron-rxdb')();
10 |
11 | // prevent windows from being garbage collected
12 | let documentWindows = [];
13 |
14 | function createMainWindow() {
15 | const win = new BrowserWindow({
16 | width: 850,
17 | height: 500,
18 | show: false,
19 | });
20 |
21 | win.loadURL(`file://${__dirname}/renderer.html`);
22 | win.once('ready-to-show', () => {
23 | win.show();
24 | })
25 | win.once('closed', () => {
26 | documentWindows = documentWindows.filter(w => w === win)
27 | });
28 |
29 | return win;
30 | }
31 |
32 | function prepareFilesystem(callback) {
33 | global.dataDirectory = path.join(app.getPath('appData'), 'Notes');
34 | console.log(`Using SQLite database at path: ${global.dataDirectory}`)
35 | fs.mkdir(global.dataDirectory, callback);
36 | }
37 |
38 | app.on('window-all-closed', () => {
39 | if (process.platform !== 'darwin') {
40 | app.quit();
41 | }
42 | });
43 |
44 | app.on('ready', () => {
45 | prepareFilesystem(() => {
46 | documentWindows.push(createMainWindow());
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/example/img/delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/attributes/attribute-number.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 | import Matcher from './matcher';
3 |
4 | /**
5 | The value of this attribute is always a number, or null.
6 | */
7 | export default class AttributeNumber extends Attribute {
8 | toJSON(val) {
9 | return val;
10 | }
11 |
12 | fromJSON(val) {
13 | return isNaN(val) ? null : Number(val);
14 | }
15 |
16 | columnSQL() {
17 | return `${this.jsonKey} INTEGER`;
18 | }
19 |
20 | // Public: Returns a {Matcher} for objects greater than the provided value.
21 | greaterThan(val) {
22 | this._assertPresentAndQueryable('greaterThan', val);
23 | return new Matcher(this, '>', val);
24 | }
25 |
26 | // Public: Returns a {Matcher} for objects less than the provided value.
27 | lessThan(val) {
28 | this._assertPresentAndQueryable('lessThan', val);
29 | return new Matcher(this, '<', val);
30 | }
31 |
32 | // Public: Returns a {Matcher} for objects greater than the provided value.
33 | greaterThanOrEqualTo(val) {
34 | this._assertPresentAndQueryable('greaterThanOrEqualTo', val);
35 | return new Matcher(this, '>=', val);
36 | }
37 |
38 | // Public: Returns a {Matcher} for objects less than the provided value.
39 | lessThanOrEqualTo(val) {
40 | this._assertPresentAndQueryable('lessThanOrEqualTo', val);
41 | return new Matcher(this, '<=', val);
42 | }
43 |
44 | gt = AttributeNumber.prototype.greaterThan;
45 | lt = AttributeNumber.prototype.lessThan;
46 | gte = AttributeNumber.prototype.greaterThanOrEqualTo;
47 | lte = AttributeNumber.prototype.lessThanOrEqualTo;
48 | }
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "electron-rxdb",
3 | "version": "0.8.3",
4 | "description": "RxDB is a high-performance, observable object store built on top of SQLite & intended for database-driven Electron applications.",
5 | "main": "lib/index.js",
6 | "dependencies": {
7 | "better-sqlite3": "git+https://github.com/bengotow/better-sqlite3.git#a888061ad334c76d2db4c06554c90785cc6e7cce",
8 | "lru-cache": "^4.0.1",
9 | "node-gyp": "^3.4.0",
10 | "promise-queue": "^2.2.3",
11 | "promise.prototype.finally": "^2.0.1",
12 | "promise.try": "^1.0.0",
13 | "rx-lite": "^4.0.8"
14 | },
15 | "devDependencies": {
16 | "babel-cli": "^6.18.0",
17 | "babel-eslint": "7.1.0",
18 | "babel-preset-electron": "^0.37.8",
19 | "babel-preset-es2016-node5": "^1.1.2",
20 | "babel-preset-react": "^6.16.0",
21 | "electron-prebuilt-compile": "^1.4.4",
22 | "esdoc-es7-plugin": "0.0.3",
23 | "eslint": "3.10.1",
24 | "eslint-config-standard": "^6.2.1",
25 | "eslint-plugin-import": "2.2.0",
26 | "eslint-plugin-jsx-a11y": "2.2.3",
27 | "eslint-plugin-promise": "^3.4.0",
28 | "eslint-plugin-react": "6.7.1",
29 | "eslint-plugin-standard": "^2.0.1",
30 | "eslint_d": "4.2.0",
31 | "jasmine": "^2.5.2"
32 | },
33 | "scripts": {
34 | "postinstall": "node ./post-install.js",
35 | "test": "CI=true electron ./spec/runner --enable-logging",
36 | "lint": "./node_modules/.bin/eslint ./src/ ./spec/",
37 | "docs": "esdoc -c esdoc.json",
38 | "compile": "rm -rf lib && babel -d lib/ src",
39 | "prepublish": "npm run compile"
40 | },
41 | "author": "",
42 | "license": "ISC"
43 | }
44 |
--------------------------------------------------------------------------------
/src/database-change-record-debouncer.js:
--------------------------------------------------------------------------------
1 | /**
2 | DatabaseChangeRecordDebouncer.accumulate is a guarded version of trigger that can accumulate changes.
3 | This means that even if you're a bad person and call `persistModel` 100 times
4 | from 100 task objects queued at the same time, it will only create one
5 | `trigger` event. This is important since the database triggering impacts
6 | the entire application.
7 |
8 | @private
9 | */
10 | export default class DatabaseChangeRecordDebouncer {
11 | constructor({onTrigger, maxTriggerDelay}) {
12 | this._options = {onTrigger, maxTriggerDelay};
13 | this._record = null;
14 | }
15 |
16 | _flushAfterDelay() {
17 | this._cancelFlush();
18 | this._flushTimer = setTimeout(() => this._flush(), this._options.maxTriggerDelay);
19 | }
20 |
21 | _cancelFlush() {
22 | if (this._flushTimer) {
23 | clearTimeout(this._flushTimer);
24 | this._flushTimer = null;
25 | }
26 | }
27 |
28 | _flush() {
29 | if (!this._record) {
30 | return;
31 | }
32 |
33 | this._cancelFlush();
34 | this._options.onTrigger(this._record);
35 | this._record = null;
36 |
37 | if (this._promiseResolve) {
38 | this._promiseResolve();
39 | this._promiseResolve = null;
40 | this._promise = null;
41 | }
42 | }
43 |
44 | accumulate(change) {
45 | this._promise = this._promise || new Promise((resolve) => {
46 | this._promiseResolve = resolve;
47 | });
48 |
49 | if (this._record && this._record.canAppend(change)) {
50 | this._record.append(change);
51 | } else {
52 | this._flush();
53 | this._record = change;
54 | this._flushAfterDelay();
55 | }
56 |
57 | return this._promise;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/example/src/components/sidebar-item.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | export default class SidebarItem extends React.Component {
5 | static propTypes = {
6 | item: React.PropTypes.object,
7 | isSelected: React.PropTypes.bool,
8 | onSelected: React.PropTypes.func,
9 | };
10 |
11 | constructor(props) {
12 | super(props);
13 | this.state = {editing: null};
14 | }
15 |
16 | _onDoubleClick = () => {
17 | const el = ReactDOM.findDOMNode(this);
18 | el.focus();
19 | const range = document.createRange();
20 | range.selectNodeContents(el);
21 | const sel = window.getSelection();
22 | sel.removeAllRanges();
23 | sel.addRange(range);
24 |
25 | this.setState({editing: true});
26 | }
27 |
28 | _onKeyDown = (event) => {
29 | if (event.keyCode === 13) {
30 | event.target.blur();
31 | }
32 | }
33 |
34 | _onBlur = () => {
35 | const item = this.props.item;
36 | item.name = ReactDOM.findDOMNode(this).innerText;
37 | window.Database.inTransaction((t) => {
38 | t.persistModel(item);
39 | });
40 | this.setState({editing: null});
41 | }
42 |
43 | render() {
44 | const {isSelected, onSelected, item} = this.props;
45 | const {editing} = this.state;
46 |
47 | const className = `item ${isSelected ? " selected" : ""} ${item.starred ? " starred" : ""}`;
48 |
49 | return (
50 |
59 | )
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/attributes/attribute-datetime.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 | import Matcher from './matcher';
3 |
4 | /**
5 | The value of this attribute is always a Javascript `Date`, or `null`.
6 | */
7 | export default class AttributeDateTime extends Attribute {
8 | toJSON(val) {
9 | if (!val) {
10 | return null;
11 | }
12 | if (!(val instanceof Date)) {
13 | throw new Error(`Attempting to toJSON AttributeDateTime which is not a date: ${this.modelKey} = ${val}`);
14 | }
15 | return val.getTime() / 1000.0;
16 | }
17 |
18 | fromJSON(val) {
19 | return val ? new Date(val * 1000) : null;
20 | }
21 |
22 | columnSQL() {
23 | return `${this.jsonKey} INTEGER`;
24 | }
25 |
26 | /**
27 | @returns {Matcher} - Matcher for objects greater than the provided value.
28 | */
29 | greaterThan(val) {
30 | this._assertPresentAndQueryable('greaterThan', val);
31 | return new Matcher(this, '>', val)
32 | }
33 |
34 | /**
35 | @returns {Matcher} - Matcher for objects less than the provided value.
36 | */
37 | lessThan(val) {
38 | this._assertPresentAndQueryable('lessThan', val);
39 | return new Matcher(this, '<', val);
40 | }
41 |
42 | /**
43 | @returns {Matcher} - Matcher for objects greater than or equal to the provided value.
44 | */
45 | greaterThanOrEqualTo(val) {
46 | this._assertPresentAndQueryable('greaterThanOrEqualTo', val);
47 | return new Matcher(this, '>=', val);
48 | }
49 |
50 | /**
51 | @returns {Matcher} - Matcher for objects less than or equal to the provided value.
52 | */
53 | lessThanOrEqualTo(val) {
54 | this._assertPresentAndQueryable('lessThanOrEqualTo', val);
55 | return new Matcher(this, '<=', val);
56 | }
57 |
58 | gt = AttributeDateTime.greaterThan;
59 | lt = AttributeDateTime.lessThan;
60 | gte = AttributeDateTime.greaterThanOrEqualTo;
61 | lte = AttributeDateTime.lessThanOrEqualTo;
62 | }
63 |
--------------------------------------------------------------------------------
/example/src/components/sidebar-search-results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SidebarItem from '../components/sidebar-item';
4 | import Note from '../models/note';
5 |
6 | export default class SidebarSearchResults extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {
10 | items: [],
11 | };
12 | }
13 |
14 | componentDidMount() {
15 | this._updateQuery(this.props);
16 | }
17 |
18 | componentWillReceiveProps(nextProps) {
19 | this._updateQuery(nextProps);
20 | }
21 |
22 | componentWillUnmount() {
23 | this._observable.dispose();
24 | }
25 |
26 | _updateQuery = ({searchValue}) => {
27 | const query = window.Database
28 | .findAll(Note)
29 | .order(Note.attributes.name.ascending())
30 |
31 | // Don't load / display > 1000 rows - DOM will start to become heavy. Need
32 | // to expand this demo to show MutableQuerySubscription "replaceRange" for
33 | // infinite scrolling result sets.
34 | query.limit(1000);
35 |
36 | if (searchValue) {
37 | query.where(Note.searchIndexes.titleAndContents.match(searchValue));
38 | }
39 |
40 | if (this._observable) {
41 | this._observable.dispose();
42 | }
43 | this._observable = query.observe().subscribe((nextItems) => {
44 | this.setState({items: nextItems});
45 | });
46 | }
47 |
48 | render() {
49 | const {selectedId, onSelectItem} = this.props;
50 | const {items} = this.state;
51 |
52 | return (
53 |
54 | {
55 | items.map((item) => {
56 | return (
57 | onSelectItem(item)}
62 | />
63 | );
64 | })
65 | }
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/docs/script/test-summary.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | function toggle(ev) {
3 | var button = ev.target;
4 | var parent = ev.target.parentElement;
5 | while(parent) {
6 | if (parent.tagName === 'TR' && parent.classList.contains('test-describe')) break;
7 | parent = parent.parentElement;
8 | }
9 |
10 | if (!parent) return;
11 |
12 | var direction;
13 | if (button.classList.contains('opened')) {
14 | button.classList.remove('opened');
15 | button.classList.add('closed');
16 | direction = 'closed';
17 | } else {
18 | button.classList.remove('closed');
19 | button.classList.add('opened');
20 | direction = 'opened';
21 | }
22 |
23 | var targetDepth = parseInt(parent.dataset.testDepth, 10) + 1;
24 | var nextElement = parent.nextElementSibling;
25 | while (nextElement) {
26 | var depth = parseInt(nextElement.dataset.testDepth, 10);
27 | if (depth >= targetDepth) {
28 | if (direction === 'opened') {
29 | if (depth === targetDepth) nextElement.style.display = '';
30 | } else if (direction === 'closed') {
31 | nextElement.style.display = 'none';
32 | var innerButton = nextElement.querySelector('.toggle');
33 | if (innerButton && innerButton.classList.contains('opened')) {
34 | innerButton.classList.remove('opened');
35 | innerButton.classList.add('closed');
36 | }
37 | }
38 | } else {
39 | break;
40 | }
41 | nextElement = nextElement.nextElementSibling;
42 | }
43 | }
44 |
45 | var buttons = document.querySelectorAll('.test-summary tr.test-describe .toggle');
46 | for (var i = 0; i < buttons.length; i++) {
47 | buttons[i].addEventListener('click', toggle);
48 | }
49 |
50 | var topDescribes = document.querySelectorAll('.test-summary tr[data-test-depth="0"]');
51 | for (var i = 0; i < topDescribes.length; i++) {
52 | topDescribes[i].style.display = '';
53 | }
54 | })();
55 |
--------------------------------------------------------------------------------
/src/attributes/attribute-joined-data.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 |
3 | const NullPlaceholder = "!NULLVALUE!";
4 |
5 | /**
6 | Joined Data attributes allow you to store certain attributes of an
7 | object in a separate table in the database. We use this attribute
8 | type for Message bodies. Storing message bodies, which can be very
9 | large, in a separate table allows us to make queries on message
10 | metadata extremely fast, and inflate Message objects without their
11 | bodies to build the thread list.
12 |
13 | When building a query on a model with a JoinedData attribute, you need
14 | to call `include` to explicitly load the joined data attribute.
15 | The query builder will automatically perform a `LEFT OUTER JOIN` with
16 | the secondary table to retrieve the attribute:
17 |
18 | ```coffee
19 | db.find(Message, '123').then (message) ->
20 | // message.body is undefined
21 |
22 | db.find(Message, '123').include(Message.attributes.body).then (message) ->
23 | // message.body is defined
24 | ```
25 |
26 | When you call `persistModel`, JoinedData attributes are automatically
27 | written to the secondary table.
28 |
29 | JoinedData attributes cannot be `queryable`.
30 |
31 | Section: Database
32 | */
33 | export default class AttributeJoinedData extends Attribute {
34 | static NullPlaceholder = NullPlaceholder;
35 |
36 | constructor({modelKey, jsonKey, modelTable, queryable}) {
37 | super({modelKey, jsonKey, queryable});
38 | this.modelTable = modelTable;
39 | }
40 |
41 | toJSON(val) {
42 | return val;
43 | }
44 |
45 | fromJSON(val) {
46 | return (val === null || val === undefined || val === false) ? null : `${val}`;
47 | }
48 |
49 | selectSQL() {
50 | // NullPlaceholder is necessary because if the LEFT JOIN returns nothing, it leaves the field
51 | // blank, and it comes through in the result row as "" rather than NULL
52 | return `IFNULL(\`${this.modelTable}\`.\`value\`, '${NullPlaceholder}') AS \`${this.modelKey}\``;
53 | }
54 |
55 | includeSQL(klass) {
56 | return `LEFT OUTER JOIN \`${this.modelTable}\` ON \`${this.modelTable}\`.\`id\` = \`${klass.name}\`.\`id\``;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/spec/fixtures/message.js:
--------------------------------------------------------------------------------
1 | import Category from './category'
2 | import Attributes from '../../src/attributes'
3 | import Model from '../../src/model'
4 |
5 | export default class Message extends Model {
6 |
7 | static attributes = Object.assign({}, Model.attributes, {
8 | accountId: Attributes.String({
9 | modelKey: 'accountId',
10 | jsonKey: 'account_id',
11 | queryable: true,
12 | }),
13 |
14 | date: Attributes.DateTime({
15 | queryable: true,
16 | modelKey: 'date',
17 | }),
18 |
19 | body: Attributes.JoinedData({
20 | modelTable: 'MessageBody',
21 | modelKey: 'body',
22 | }),
23 |
24 | files: Attributes.Collection({
25 | modelKey: 'files',
26 | itemClass: File,
27 | }),
28 |
29 | uploads: Attributes.Object({
30 | queryable: false,
31 | modelKey: 'uploads',
32 | }),
33 |
34 | unread: Attributes.Boolean({
35 | queryable: true,
36 | modelKey: 'unread',
37 | }),
38 |
39 | events: Attributes.Collection({
40 | modelKey: 'events',
41 | itemClass: Event,
42 | }),
43 |
44 | starred: Attributes.Boolean({
45 | queryable: true,
46 | modelKey: 'starred',
47 | }),
48 |
49 | snippet: Attributes.String({
50 | modelKey: 'snippet',
51 | }),
52 |
53 | threadId: Attributes.String({
54 | queryable: true,
55 | modelKey: 'threadId',
56 | jsonKey: 'thread_id',
57 | }),
58 |
59 | subject: Attributes.String({
60 | modelKey: 'subject',
61 | }),
62 |
63 | draft: Attributes.Boolean({
64 | modelKey: 'draft',
65 | jsonKey: 'draft',
66 | queryable: true,
67 | }),
68 |
69 | pristine: Attributes.Boolean({
70 | modelKey: 'pristine',
71 | jsonKey: 'pristine',
72 | queryable: false,
73 | }),
74 |
75 | version: Attributes.Number({
76 | modelKey: 'version',
77 | queryable: true,
78 | }),
79 |
80 | replyToMessageId: Attributes.String({
81 | modelKey: 'replyToMessageId',
82 | jsonKey: 'reply_to_message_id',
83 | }),
84 |
85 | categories: Attributes.Collection({
86 | modelKey: 'categories',
87 | itemClass: Category,
88 | }),
89 | });
90 | }
91 |
--------------------------------------------------------------------------------
/spec/model-registry-spec.js:
--------------------------------------------------------------------------------
1 | /* eslint quote-props: 0 */
2 | import Model from '../src/model';
3 | import Attributes from '../src/attributes';
4 | import ModelRegistry from '../src/model-registry';
5 |
6 | class GoodTest extends Model {
7 | static attributes = Object.assign({}, Model.attributes, {
8 | "foo": Attributes.String({
9 | modelKey: 'foo',
10 | jsonKey: 'foo',
11 | }),
12 | });
13 | }
14 |
15 | class BetterTest extends Model {
16 | static attributes = Object.assign({}, Model.attributes, {
17 | "bar": Attributes.String({
18 | modelKey: 'bar',
19 | jsonKey: 'bar',
20 | }),
21 | });
22 | }
23 |
24 | class RandomClass {
25 |
26 | }
27 |
28 | describe('ModelRegistry', function ModelRegistrySpecs() {
29 | beforeEach(() => {
30 | this.registry = new ModelRegistry();
31 | this.registry.registerDeferred({name: "GoodTest", resolver: () => GoodTest});
32 | });
33 |
34 | describe("registerDeferred", () => {
35 | it("can register constructors", () => {
36 | const testFn = () => BetterTest;
37 | this.registry.registerDeferred({name: "BetterTest", resolver: testFn});
38 | expect(this.registry.get("BetterTest")).toBe(BetterTest);
39 | });
40 | });
41 |
42 | describe("has", () => {
43 | it("tests if a constructor is in the registry", () => {
44 | expect(this.registry.has("GoodTest")).toEqual(true);
45 | expect(this.registry.has("BadTest")).toEqual(false);
46 | });
47 | });
48 |
49 | describe("deserialize", () => {
50 | it("deserializes the objects for a constructor", () => {
51 | const obj = this.registry.deserialize("GoodTest", {foo: "bar"});
52 | expect(obj instanceof GoodTest).toBe(true);
53 | expect(obj.foo).toBe("bar");
54 | });
55 |
56 | it("throws an error if the object can't be deserialized", () =>
57 | expect(() => this.registry.deserialize("BadTest", {foo: "bar"})).toThrow()
58 | );
59 |
60 | it("throws if the registered constructor was not a model subclass", () => {
61 | this.registry.registerDeferred({name: "RandomClass", resolver: () => RandomClass});
62 | expect(() => this.registry.deserialize("RandomClass", {foo: "bar"})).toThrow();
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/example/src/components/container.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import WikipediaButton from './wikipedia-button'
3 |
4 | import SidebarSearchResults from '../components/sidebar-search-results';
5 | import SidebarRecents from '../components/sidebar-recents';
6 | import Detail from '../components/detail';
7 | import Note from '../models/note';
8 |
9 | export default class Container extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | selectedId: null,
14 | searchValue: '',
15 | };
16 | }
17 |
18 | _onSearchChange = (event) => {
19 | this.setState({searchValue: event.target.value});
20 | }
21 |
22 | _onSelectItem = (item) => {
23 | this.setState({selectedId: item.id});
24 | }
25 |
26 | _onCreateItem = () => {
27 | const item = new Note({
28 | name: 'Untitled',
29 | content: 'Write your note here!',
30 | updatedAt: new Date(),
31 | });
32 |
33 | window.Database.inTransaction((t) => {
34 | return t.persistModel(item);
35 | }).then(() => {
36 | this.setState({
37 | selectedId: item.id,
38 | searchValue: '',
39 | });
40 | });
41 | }
42 |
43 | render() {
44 | const {selectedId, searchValue} = this.state;
45 |
46 | return (
47 |
48 |
49 |
50 |
56 |
57 |
62 |
65 |
66 |
67 |
68 |
69 |
74 |
75 |
76 | )
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/database-change-record.js:
--------------------------------------------------------------------------------
1 | /**
2 | An RxDB database emits DatabaseChangeRecord objects once a transaction has completed.
3 | The change record contains a copy of the model(s) that were modified, the type of
4 | modification (persist / destroy) and the model class.
5 |
6 | DatabaseChangeRecords can be serialized to JSON and RxDB transparently bridges
7 | them between windows of Electron applications. Change records of the same type
8 | and model class can also be merged.
9 | */
10 | export default class DatabaseChangeRecord {
11 |
12 | constructor(database, options) {
13 | this._database = database;
14 | this._options = options;
15 |
16 | // When DatabaseChangeRecords are sent over IPC to other windows, their object
17 | // payload is sub-serialized into a JSON string. This means that we can wait
18 | // to deserialize models until someone in the window asks for `change.objects`
19 | this._objects = options.objects;
20 | this._objectsString = options.objectsString;
21 |
22 | Object.defineProperty(this, 'type', {
23 | get: () => options.type,
24 | })
25 | Object.defineProperty(this, 'objectClass', {
26 | get: () => options.objectClass,
27 | })
28 | Object.defineProperty(this, 'objects', {
29 | get: () => {
30 | this._objects = this._objects || JSON.parse(this._objectsString, this._database.models.JSONReviver);
31 | return this._objects;
32 | },
33 | })
34 | }
35 |
36 | canAppend(other) {
37 | return (this.objectClass === other.objectClass) && (this.type === other.type);
38 | }
39 |
40 | append(other) {
41 | if (!this._indexLookup) {
42 | this._indexLookup = {};
43 | this.objects.forEach((obj, idx) => {
44 | this._indexLookup[obj.id] = idx;
45 | });
46 | }
47 |
48 | // When we join new models into our set, replace existing ones so the same
49 | // model cannot exist in the change record set multiple times.
50 | for (const obj of other.objects) {
51 | const idx = this._indexLookup[obj.id]
52 | if (idx) {
53 | this.objects[idx] = obj;
54 | } else {
55 | this._indexLookup[obj.id] = this.objects.length
56 | this.objects.push(obj);
57 | }
58 | }
59 | }
60 |
61 | toJSON() {
62 | this._objectsString = this._objectsString || JSON.stringify(this._objects, this._database.models.JSONReplacer);
63 | return {
64 | type: this.type,
65 | objectClass: this.objectClass,
66 | objectsString: this._objectsString,
67 | };
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/spec/query-subscription-pool-spec.js:
--------------------------------------------------------------------------------
1 | import QuerySubscriptionPool from '../src/query-subscription-pool';
2 | import {Database, Category} from './fixtures';
3 |
4 | describe("QuerySubscriptionPool", function QuerySubscriptionPoolSpecs() {
5 | beforeEach(() => {
6 | this.pool = new QuerySubscriptionPool(Database);
7 | this.query = Database.findAll(Category);
8 | this.queryKey = this.query.sql();
9 | });
10 |
11 | describe("add", () => {
12 | it("should add a new subscription with the callback", () => {
13 | const callback = jasmine.createSpy('callback');
14 | this.pool.add(this.query, callback);
15 | expect(this.pool._subscriptions[this.queryKey]).toBeDefined();
16 |
17 | const subscription = this.pool._subscriptions[this.queryKey];
18 | expect(subscription.hasCallback(callback)).toBe(true);
19 | });
20 |
21 | it("should yield database changes to the subscription", () => {
22 | const callback = jasmine.createSpy('callback');
23 | this.pool.add(this.query, callback);
24 | const subscription = this.pool._subscriptions[this.queryKey];
25 | spyOn(subscription, 'applyChangeRecord');
26 |
27 | const record = {objectType: 'whateves'};
28 | this.pool._onChange(record);
29 | expect(subscription.applyChangeRecord).toHaveBeenCalledWith(record);
30 | });
31 |
32 | describe("unsubscribe", () => {
33 | it("should return an unsubscribe method", () => {
34 | expect(this.pool.add(this.query, () => {}) instanceof Function).toBe(true);
35 | });
36 |
37 | it("should remove the callback from the subscription", () => {
38 | const cb = () => {};
39 |
40 | const unsub = this.pool.add(this.query, cb);
41 | const subscription = this.pool._subscriptions[this.queryKey];
42 |
43 | expect(subscription.hasCallback(cb)).toBe(true);
44 | unsub();
45 | expect(subscription.hasCallback(cb)).toBe(false);
46 | });
47 |
48 | it("should wait before removing the subscription to make sure it's not reused", () => {
49 | jasmine.clock().install();
50 |
51 | const unsub = this.pool.add(this.query, () => {});
52 | expect(this.pool._subscriptions[this.queryKey]).toBeDefined();
53 | unsub();
54 | expect(this.pool._subscriptions[this.queryKey]).toBeDefined();
55 | jasmine.clock().tick(2);
56 | expect(this.pool._subscriptions[this.queryKey]).toBeUndefined();
57 |
58 | jasmine.clock().uninstall();
59 | });
60 | });
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/src/attributes/attribute.js:
--------------------------------------------------------------------------------
1 | import Matcher from './matcher';
2 | import SortOrder from './sort-order';
3 |
4 | /**
5 | The Attribute class represents a single model attribute, like 'account_id'.
6 | Subclasses of {Attribute} like {AttributeDateTime} know how to covert between
7 | the JSON representation of that type and the javascript representation.
8 | The Attribute class also exposes convenience methods for generating {Matcher} objects.
9 | */
10 | export default class Attribute {
11 | constructor({modelKey, queryable, jsonKey}) {
12 | this.modelKey = modelKey;
13 | this.jsonKey = jsonKey || modelKey;
14 | this.queryable = queryable;
15 | }
16 |
17 | _assertPresentAndQueryable(fnName, val) {
18 | if (val === undefined) {
19 | throw new Error(`Attribute::${fnName} (${this.modelKey}) - you must provide a value`);
20 | }
21 | if (!this.queryable) {
22 | throw new Error(`Attribute::${fnName} (${this.modelKey}) - this field cannot be queried against`);
23 | }
24 | }
25 |
26 | /**
27 | @param val - The attribute value
28 | @returns {Matcher} - Matcher for objects `=` to the provided value.
29 | */
30 | equal(val) {
31 | this._assertPresentAndQueryable('equal', val);
32 | return new Matcher(this, '=', val);
33 | }
34 |
35 | /**
36 | @param {Array} val - An array of values
37 | @returns {Matcher} - Matcher for objects in the provided array.
38 | */
39 | in(val) {
40 | this._assertPresentAndQueryable('in', val);
41 |
42 | if (!(val instanceof Array)) {
43 | throw new Error(`Attribute.in: you must pass an array of values.`);
44 | }
45 | if (val.length === 0) {
46 | console.warn(`Attribute::in (${this.modelKey}) called with an empty set. You should avoid this useless query!`);
47 | }
48 | return (val.length === 1) ? new Matcher(this, '=', val[0]) : new Matcher(this, 'in', val);
49 | }
50 |
51 | /**
52 | @param val - The attribute value
53 | @returns {Matcher} - A matcher for objects `!=` to the provided value.
54 | */
55 | not(val) {
56 | this._assertPresentAndQueryable('not', val);
57 | return new Matcher(this, '!=', val);
58 | }
59 |
60 | /**
61 | @returns {SortOrder} - Returns a descending sort order for this attribute
62 | */
63 | descending() {
64 | return new SortOrder(this, 'DESC');
65 | }
66 |
67 | /**
68 | @returns {SortOrder} - Returns an ascending sort order for this attribute
69 | */
70 | ascending() {
71 | return new SortOrder(this, 'ASC');
72 | }
73 |
74 | toJSON(val) {
75 | return val;
76 | }
77 |
78 | fromJSON(val) {
79 | return val || null;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/docs/css/prettify-tomorrow.css:
--------------------------------------------------------------------------------
1 | /* Tomorrow Theme */
2 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */
3 | /* Pretty printing styles. Used with prettify.js. */
4 | /* SPAN elements with the classes below are added by prettyprint. */
5 | /* plain text */
6 | .pln {
7 | color: #4d4d4c; }
8 |
9 | @media screen {
10 | /* string content */
11 | .str {
12 | color: #718c00; }
13 |
14 | /* a keyword */
15 | .kwd {
16 | color: #8959a8; }
17 |
18 | /* a comment */
19 | .com {
20 | color: #8e908c; }
21 |
22 | /* a type name */
23 | .typ {
24 | color: #4271ae; }
25 |
26 | /* a literal value */
27 | .lit {
28 | color: #f5871f; }
29 |
30 | /* punctuation */
31 | .pun {
32 | color: #4d4d4c; }
33 |
34 | /* lisp open bracket */
35 | .opn {
36 | color: #4d4d4c; }
37 |
38 | /* lisp close bracket */
39 | .clo {
40 | color: #4d4d4c; }
41 |
42 | /* a markup tag name */
43 | .tag {
44 | color: #c82829; }
45 |
46 | /* a markup attribute name */
47 | .atn {
48 | color: #f5871f; }
49 |
50 | /* a markup attribute value */
51 | .atv {
52 | color: #3e999f; }
53 |
54 | /* a declaration */
55 | .dec {
56 | color: #f5871f; }
57 |
58 | /* a variable name */
59 | .var {
60 | color: #c82829; }
61 |
62 | /* a function name */
63 | .fun {
64 | color: #4271ae; } }
65 | /* Use higher contrast and text-weight for printable form. */
66 | @media print, projection {
67 | .str {
68 | color: #060; }
69 |
70 | .kwd {
71 | color: #006;
72 | font-weight: bold; }
73 |
74 | .com {
75 | color: #600;
76 | font-style: italic; }
77 |
78 | .typ {
79 | color: #404;
80 | font-weight: bold; }
81 |
82 | .lit {
83 | color: #044; }
84 |
85 | .pun, .opn, .clo {
86 | color: #440; }
87 |
88 | .tag {
89 | color: #006;
90 | font-weight: bold; }
91 |
92 | .atn {
93 | color: #404; }
94 |
95 | .atv {
96 | color: #060; } }
97 | /* Style */
98 | /*
99 | pre.prettyprint {
100 | background: white;
101 | font-family: Consolas, Monaco, 'Andale Mono', monospace;
102 | font-size: 12px;
103 | line-height: 1.5;
104 | border: 1px solid #ccc;
105 | padding: 10px; }
106 | */
107 |
108 | /* Specify class=linenums on a pre to get line numbering */
109 | ol.linenums {
110 | margin-top: 0;
111 | margin-bottom: 0; }
112 |
113 | /* IE indents via margin-left */
114 | li.L0,
115 | li.L1,
116 | li.L2,
117 | li.L3,
118 | li.L4,
119 | li.L5,
120 | li.L6,
121 | li.L7,
122 | li.L8,
123 | li.L9 {
124 | /* */ }
125 |
126 | /* Alternate shading for lines */
127 | li.L1,
128 | li.L3,
129 | li.L5,
130 | li.L7,
131 | li.L9 {
132 | /* */ }
133 |
--------------------------------------------------------------------------------
/src/query-range.js:
--------------------------------------------------------------------------------
1 | /**
2 | QueryRange represents a LIMIT + OFFSET pair and provides high-level methods
3 | for comparing, copying and joining ranges. It also provides syntax sugar
4 | around infinity (the absence of a defined LIMIT or OFFSET).
5 | */
6 | export default class QueryRange {
7 | static infinite() {
8 | return new QueryRange({limit: null, offset: null});
9 | }
10 |
11 | static rangeWithUnion(a, b) {
12 | if (a.isInfinite() || b.isInfinite()) {
13 | return QueryRange.infinite();
14 | }
15 | if (!a.isContiguousWith(b)) {
16 | throw new Error('You cannot union ranges which do not touch or intersect.');
17 | }
18 |
19 | return new QueryRange({
20 | start: Math.min(a.start, b.start),
21 | end: Math.max(a.end, b.end),
22 | });
23 | }
24 |
25 | static rangesBySubtracting(a, b) {
26 | if (!b) {
27 | return [];
28 | }
29 |
30 | if (a.isInfinite() || b.isInfinite()) {
31 | throw new Error("You cannot subtract infinite ranges.");
32 | }
33 |
34 | const uncovered = []
35 | if (b.start > a.start) {
36 | uncovered.push(new QueryRange({start: a.start, end: Math.min(a.end, b.start)}))
37 | }
38 | if (b.end < a.end) {
39 | uncovered.push(new QueryRange({start: Math.max(a.start, b.end), end: a.end}));
40 | }
41 | return uncovered;
42 | }
43 |
44 | get start() {
45 | return this.offset;
46 | }
47 |
48 | get end() {
49 | return this.offset + this.limit;
50 | }
51 |
52 | constructor({limit, offset, start, end} = {}) {
53 | this.limit = limit;
54 | this.offset = offset;
55 |
56 | if ((start !== undefined) && (offset === undefined)) {
57 | this.offset = start;
58 | }
59 | if ((end !== undefined) && (limit === undefined)) {
60 | this.limit = end - this.offset;
61 | }
62 |
63 | if (this.limit === undefined) {
64 | throw new Error("You must specify a limit");
65 | }
66 | if (this.offset === undefined) {
67 | throw new Error("You must specify an offset");
68 | }
69 | }
70 |
71 | clone() {
72 | const {limit, offset} = this;
73 | return new QueryRange({limit, offset});
74 | }
75 |
76 | isInfinite() {
77 | return (this.limit === null) && (this.offset === null);
78 | }
79 |
80 | isEqual(b) {
81 | return (this.start === b.start) && (this.end === b.end);
82 | }
83 |
84 | // Returns true if joining the two ranges would not result in empty space.
85 | // ie: they intersect or touch
86 | isContiguousWith(b) {
87 | if (this.isInfinite() || b.isInfinite()) {
88 | return true;
89 | }
90 | return ((this.start <= b.start) && (b.start <= this.end)) || ((this.start <= b.end) && (b.end <= this.end));
91 | }
92 |
93 | toString() {
94 | return `QueryRange{${this.start} - ${this.end}}`;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/spec/fixtures/test-model.js:
--------------------------------------------------------------------------------
1 | /* eslint quote-props: 0 */
2 | import Model from '../../src/model';
3 | import Attributes from '../../src/attributes';
4 |
5 | import Category from './category';
6 |
7 | class TestModel extends Model {
8 | static attributes = {
9 | 'id': Attributes.String({
10 | queryable: true,
11 | modelKey: 'id',
12 | }),
13 | };
14 | }
15 |
16 | TestModel.configureBasic = () => {
17 | TestModel.additionalSQLiteConfig = undefined
18 | TestModel.attributes = {
19 | 'id': Attributes.String({
20 | queryable: true,
21 | modelKey: 'id',
22 | }),
23 | }
24 | }
25 |
26 | TestModel.configureWithAllAttributes = () => {
27 | TestModel.additionalSQLiteConfig = undefined;
28 | TestModel.attributes = {
29 | 'datetime': Attributes.DateTime({
30 | queryable: true,
31 | modelKey: 'datetime',
32 | }),
33 | 'string': Attributes.String({
34 | queryable: true,
35 | modelKey: 'string',
36 | jsonKey: 'string-json-key',
37 | }),
38 | 'boolean': Attributes.Boolean({
39 | queryable: true,
40 | modelKey: 'boolean',
41 | }),
42 | 'number': Attributes.Number({
43 | queryable: true,
44 | modelKey: 'number',
45 | }),
46 | 'other': Attributes.String({
47 | modelKey: 'other',
48 | }),
49 | }
50 | }
51 |
52 | TestModel.configureWithCollectionAttribute = () => {
53 | TestModel.additionalSQLiteConfig = undefined;
54 | TestModel.attributes = {
55 | 'id': Attributes.String({
56 | queryable: true,
57 | modelKey: 'id',
58 | }),
59 | 'other': Attributes.String({
60 | queryable: true,
61 | modelKey: 'other',
62 | }),
63 | 'categories': Attributes.Collection({
64 | queryable: true,
65 | modelKey: 'categories',
66 | itemClass: Category,
67 | joinOnField: 'id',
68 | joinQueryableBy: ['other'],
69 | }),
70 | }
71 | }
72 |
73 | TestModel.configureWithJoinedDataAttribute = () => {
74 | TestModel.additionalSQLiteConfig = undefined;
75 | TestModel.attributes = {
76 | 'id': Attributes.String({
77 | queryable: true,
78 | modelKey: 'id',
79 | }),
80 | 'body': Attributes.JoinedData({
81 | modelTable: 'TestModelBody',
82 | modelKey: 'body',
83 | }),
84 | }
85 | }
86 |
87 | TestModel.configureWithAdditionalSQLiteConfig = () => {
88 | TestModel.attributes = {
89 | 'id': Attributes.String({
90 | queryable: true,
91 | modelKey: 'id',
92 | }),
93 | 'body': Attributes.JoinedData({
94 | modelTable: 'TestModelBody',
95 | modelKey: 'body',
96 | }),
97 | };
98 | TestModel.additionalSQLiteConfig = {
99 | setup: () => ['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)'],
100 | };
101 | }
102 |
103 | module.exports = TestModel
104 |
--------------------------------------------------------------------------------
/example/src/components/detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Note from '../models/note';
3 |
4 | export default class Detail extends React.Component {
5 | static propTypes = {
6 | itemId: React.PropTypes.string,
7 | };
8 |
9 | constructor(props) {
10 | super(props);
11 | this.state = {item: null};
12 | }
13 |
14 | componentDidMount(props) {
15 | this._updateQuery(props);
16 | }
17 |
18 | componentWillReceiveProps(nextProps) {
19 | if (nextProps.itemId !== this.props.itemId) {
20 | this._updateQuery(nextProps);
21 | }
22 | }
23 |
24 | componentWillUnmount() {
25 | this._updateQuery(null);
26 | }
27 |
28 | _updateQuery = ({itemId} = {}) => {
29 | if (this._observable) {
30 | this._observable.dispose();
31 | this._observable = null;
32 | }
33 |
34 | if (itemId) {
35 | const query = window.Database.find(Note, itemId);
36 | this._observable = query.observe().subscribe((item) => {
37 | this.setState({item});
38 | });
39 | } else {
40 | this.setState({item: null});
41 | }
42 | }
43 |
44 | _onBlur = () => {
45 | const {item} = this.state;
46 | window.Database.inTransaction((t) => {
47 | item.content = this.refs.content.innerHTML;
48 | item.name = this.refs.name.innerText;
49 | item.updatedAt = new Date();
50 | return t.persistModel(item);
51 | });
52 | }
53 |
54 | _onToggleStarred = () => {
55 | const {item} = this.state;
56 | window.Database.inTransaction((t) => {
57 | item.starred = !item.starred;
58 | return t.persistModel(item);
59 | });
60 | }
61 |
62 | _onDelete = () => {
63 | window.Database.inTransaction((t) => {
64 | return t.unpersistModel(this.state.item);
65 | });
66 | }
67 |
68 | render() {
69 | if (!this.state.item) {
70 | return (
71 |
72 |
Please select an item
73 |
74 | );
75 | }
76 |
77 | const {name, content, starred} = this.state.item;
78 | return (
79 |
80 |
81 |
85 |
89 |
90 |
96 |
103 |
104 | );
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/browser/coordinator.js:
--------------------------------------------------------------------------------
1 | import {BrowserWindow, ipcMain} from 'electron';
2 | import fs from 'fs';
3 |
4 | /**
5 | To use RxDB, you need to attach the coordinator in your Electron browser process.
6 | It handles message dispatch across windows and manages the state of database
7 | files when they need to be created before use. Just import it and instantiate one:
8 |
9 | ```js
10 | import {Coordinator} from 'electron-rxdb';
11 | global._coordinator = new Coordinator();
12 | ```
13 | */
14 | export default class Coordinator {
15 | constructor() {
16 | this._phase = 'setup';
17 |
18 | ipcMain.on('rxdb-get-phase', (event) => {
19 | event.returnValue = this._phase;
20 | });
21 |
22 | ipcMain.on('rxdb-set-phase', (event, phase) => {
23 | this.setPhase(phase);
24 | });
25 |
26 | ipcMain.on('rxdb-handle-setup-error', () => {
27 | this.recoverFromFatalDatabaseError();
28 | });
29 |
30 | ipcMain.on('rxdb-trigger', (event, ...args) => {
31 | const sender = BrowserWindow.fromWebContents(event.sender);
32 | BrowserWindow.getAllWindows().forEach((win) => {
33 | if (win !== sender) {
34 | win.webContents.send('rxdb-trigger', ...args);
35 | }
36 | });
37 | });
38 | }
39 |
40 | setPhase(phase) {
41 | this._phase = phase;
42 | BrowserWindow.getAllWindows().forEach((win) => {
43 | win.webContents.send('rxdb-phase-changed', phase);
44 | });
45 | }
46 |
47 | recoverFromFatalDatabaseError(databasePath) {
48 | setTimeout(() => {
49 | if (this._databasePhase === 'close') {
50 | return;
51 | }
52 | this.setPhase('close');
53 | this.deleteDatabase(databasePath, () => {
54 | this.setPhase('setup');
55 | BrowserWindow.getAllWindows().forEach((win) => {
56 | win.reload();
57 | });
58 | });
59 | }, 0);
60 | }
61 |
62 | deleteDatabase(databasePath, callback) {
63 | this.deleteFileWithRetry(`${databasePath}-wal`);
64 | this.deleteFileWithRetry(`${databasePath}-shm`);
65 | this.deleteFileWithRetry(databasePath, callback);
66 | }
67 |
68 | // On Windows, removing a file can fail if a process still has it open. When
69 | // we close windows and log out, we need to wait for these processes to completely
70 | // exit and then delete the file. It's hard to tell when this happens, so we just
71 | // retry the deletion a few times.
72 | deleteFileWithRetry(filePath, callback = () => {}, retries = 5) {
73 | const callbackWithRetry = (err) => {
74 | if (err && (err.message.indexOf('no such file') === -1)) {
75 | console.log(`File Error: ${err.message} - retrying in 150msec`);
76 | setTimeout(() => {
77 | this.deleteFileWithRetry(filePath, callback, retries - 1);
78 | }, 150);
79 | } else {
80 | callback(null);
81 | }
82 | }
83 |
84 | if (!fs.existsSync(filePath)) {
85 | callback(null);
86 | return
87 | }
88 |
89 | if (retries > 0) {
90 | fs.unlink(filePath, callbackWithRetry);
91 | } else {
92 | fs.unlink(filePath, callback);
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/post-install.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /*
3 | Technically, this script shouldn't exist! If you're using electron-rxdb in an
4 | Electron project set up with `electron-rebuild`, native node modules will be
5 | automatically recompiled for Electron's version of V8.
6 |
7 | However, if electron-rxdb is your only node module using native code, you might
8 | not have that set up. SQLite3 is also notoriously difficult to build on Windows.
9 | For me, it only works with VS2013.
10 |
11 | This post-install script manually detects Electron in a package.json file in
12 | a parent directory, and recompiles sqlite3, so you can get on to the fun stuff.
13 | */
14 |
15 | var execSync = require('child_process').execSync;
16 | var fs = require('fs');
17 | var path = require('path');
18 |
19 | function electronVersionFromPackageJSON(jsonPath) {
20 | var data = fs.readFileSync(jsonPath);
21 | var json = JSON.parse(data);
22 | var deps = Object.assign({}, json.dependencies, json.devDependencies);
23 | var version = deps['electron'] || deps['electron-prebuilt'] || deps['electron-prebuilt-compile'];
24 | if (version) {
25 | return version.replace('^', '').replace('x', '0');
26 | }
27 | return null;
28 | }
29 |
30 | // find out what version of Electron we should build against
31 | var targetElectronVersion = null;
32 | var dir = path.dirname(__dirname);
33 |
34 | while (dir !== '/') {
35 | try {
36 | targetElectronVersion = electronVersionFromPackageJSON(path.join(dir, 'package.json'));
37 | if (targetElectronVersion) {
38 | break;
39 | }
40 | } catch (er) {
41 |
42 | } finally {
43 | dir = path.dirname(dir);
44 | }
45 | }
46 |
47 | if (targetElectronVersion === '*') {
48 | throw new Error("Electron version is specified as `*` in your package.json file.\nYou should lock it to at least a minor version, like ^1.4.0.")
49 | }
50 |
51 | if (!targetElectronVersion) {
52 | targetElectronVersion = electronVersionFromPackageJSON(path.join(__dirname, 'package.json'));
53 | console.warn("NOTE: Could not find `electron`, `electron-prebuilt` or `electron-prebuilt-compile`\nin a package.json file in the working path. Building SQLite3 for local\nElectron (v"+targetElectronVersion+") and `npm test`.");
54 | }
55 |
56 | // prepare other params
57 | var targetArch = require('os').arch();
58 | var targetPlatform = process.platform;
59 | if (targetPlatform == "win32") {
60 | var targetArch = "ia32"
61 | }
62 |
63 | var pathToNodeGyp = [
64 | path.resolve(__dirname, 'node_modules', '.bin', 'node-gyp'),
65 | path.resolve(__dirname, '..', '.bin', 'node-gyp'),
66 | ].find((p) => fs.existsSync(p));
67 | if (!pathToNodeGyp) {
68 | throw new Error("Couldn't find node-gyp");
69 | }
70 |
71 | var pathToSqlite = [
72 | '../better-sqlite3',
73 | './node_modules/better-sqlite3'
74 | ].find((p) => fs.existsSync(p));
75 | if (!pathToSqlite) {
76 | throw new Error("Couldn't find better-sqlite3");
77 | }
78 |
79 | var cmd = "cd " + pathToSqlite + " && \""+pathToNodeGyp+"\" configure rebuild --msvs_version=2013 --target="+targetElectronVersion+" --arch="+targetArch+" --target_platform="+targetPlatform+" --dist-url=https://atom.io/download/electron";
80 | console.log(cmd);
81 | execSync(cmd);
82 |
--------------------------------------------------------------------------------
/src/query-builder.js:
--------------------------------------------------------------------------------
1 | /* eslint global-require:0 */
2 | import {tableNameForJoin} from './utils';
3 | import Attributes from './attributes';
4 |
5 | const {AttributeCollection, AttributeJoinedData} = Attributes;
6 |
7 | /**
8 | The factory methods in this class assemble SQL queries that build Model
9 | tables based on their attribute schema.
10 |
11 | @private
12 | */
13 | export function analyzeQueriesForClass(klass) {
14 | const queries = [];
15 |
16 | const attributes = Object.keys(klass.attributes).map(k => klass.attributes[k]);
17 | const collectionAttributes = attributes.filter((attr) =>
18 | attr.queryable && attr instanceof AttributeCollection
19 | )
20 |
21 | queries.push(`ANALYZE \`${klass.name}\``);
22 | collectionAttributes.forEach((attribute) => {
23 | queries.push(`ANALYZE \`${tableNameForJoin(klass, attribute.itemClass)}\``)
24 | });
25 |
26 | return queries;
27 | }
28 |
29 | export function setupQueriesForClass(klass) {
30 | const attributes = Object.keys(klass.attributes).map(k => klass.attributes[k]);
31 | let queries = [];
32 |
33 | // Identify attributes of this class that can be matched against. These
34 | // attributes need their own columns in the table
35 | const columnAttributes = attributes.filter(attr =>
36 | attr.queryable && attr.columnSQL && attr.jsonKey !== 'id'
37 | );
38 |
39 | const columns = ['id TEXT PRIMARY KEY', 'data BLOB']
40 | columnAttributes.forEach(attr => columns.push(attr.columnSQL()));
41 |
42 | const columnsSQL = columns.join(',');
43 | queries.unshift(`CREATE TABLE IF NOT EXISTS \`${klass.name}\` (${columnsSQL})`);
44 | queries.push(`CREATE UNIQUE INDEX IF NOT EXISTS \`${klass.name}_id\` ON \`${klass.name}\` (\`id\`)`);
45 |
46 | // Identify collection attributes that can be matched against. These require
47 | // JOIN tables. (Right now the only one of these is Thread.folders or
48 | // Thread.categories)
49 | const collectionAttributes = attributes.filter(attr =>
50 | attr.queryable && attr instanceof AttributeCollection
51 | );
52 | collectionAttributes.forEach((attribute) => {
53 | const joinTable = tableNameForJoin(klass, attribute.itemClass);
54 | const joinColumns = attribute.joinQueryableBy.map((name) =>
55 | klass.attributes[name].columnSQL()
56 | );
57 | joinColumns.unshift('id TEXT KEY', '`value` TEXT');
58 |
59 | queries.push(`CREATE TABLE IF NOT EXISTS \`${joinTable}\` (${joinColumns.join(',')})`);
60 | queries.push(`CREATE INDEX IF NOT EXISTS \`${joinTable.replace('-', '_')}_id\` ON \`${joinTable}\` (\`id\` ASC)`);
61 | queries.push(`CREATE UNIQUE INDEX IF NOT EXISTS \`${joinTable.replace('-', '_')}_val_id\` ON \`${joinTable}\` (\`value\` ASC, \`id\` ASC)`);
62 | });
63 |
64 | const joinedDataAttributes = attributes.filter(attr =>
65 | attr instanceof AttributeJoinedData
66 | )
67 |
68 | joinedDataAttributes.forEach((attribute) => {
69 | queries.push(`CREATE TABLE IF NOT EXISTS \`${attribute.modelTable}\` (id TEXT PRIMARY KEY, \`value\` TEXT)`);
70 | });
71 |
72 | if (klass.additionalSQLiteConfig && klass.additionalSQLiteConfig.setup) {
73 | queries = queries.concat(klass.additionalSQLiteConfig.setup());
74 | }
75 |
76 | return queries;
77 | }
78 |
--------------------------------------------------------------------------------
/spec/database-setup-query-builder-spec.js:
--------------------------------------------------------------------------------
1 | /* eslint quote-props: 0 */
2 | import TestModel from './fixtures/test-model';
3 | import Attributes from '../src/attributes';
4 | import {setupQueriesForClass} from '../src/query-builder';
5 |
6 | describe("QueryBuilder", function QueryBuilderSpecs() {
7 | describe("setupQueriesForClass", () => {
8 | it("should return the queries for creating the table and the primary unique index", () => {
9 | TestModel.attributes = {
10 | 'attrQueryable': Attributes.DateTime({
11 | queryable: true,
12 | modelKey: 'attrQueryable',
13 | jsonKey: 'attr_queryable',
14 | }),
15 |
16 | 'attrNonQueryable': Attributes.Collection({
17 | modelKey: 'attrNonQueryable',
18 | jsonKey: 'attr_non_queryable',
19 | }),
20 | };
21 | const queries = setupQueriesForClass(TestModel);
22 | const expected = [
23 | 'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,attr_queryable INTEGER)',
24 | 'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
25 | ];
26 | queries.map((query, i) =>
27 | expect(query).toBe(expected[i])
28 | );
29 | });
30 |
31 | it("should correctly create join tables for models that have queryable collections", () => {
32 | TestModel.configureWithCollectionAttribute();
33 | const queries = setupQueriesForClass(TestModel);
34 | const expected = [
35 | 'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,other TEXT)',
36 | 'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
37 | 'CREATE TABLE IF NOT EXISTS `TestModelCategory` (id TEXT KEY,`value` TEXT,other TEXT)',
38 | 'CREATE INDEX IF NOT EXISTS `TestModelCategory_id` ON `TestModelCategory` (`id` ASC)',
39 | 'CREATE UNIQUE INDEX IF NOT EXISTS `TestModelCategory_val_id` ON `TestModelCategory` (`value` ASC, `id` ASC)',
40 | ];
41 | queries.map((query, i) =>
42 | expect(query).toBe(expected[i])
43 | );
44 | });
45 |
46 | it("should use the correct column type for each attribute", () => {
47 | TestModel.configureWithAllAttributes();
48 | const queries = setupQueriesForClass(TestModel);
49 | expect(queries[0]).toBe('CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,datetime INTEGER,string-json-key TEXT,boolean INTEGER,number INTEGER)');
50 | });
51 |
52 | describe("when the model provides additional sqlite config", () => {
53 | it("the setup method should return these queries", () => {
54 | TestModel.configureWithAdditionalSQLiteConfig();
55 | spyOn(TestModel.additionalSQLiteConfig, 'setup').and.callThrough();
56 | const queries = setupQueriesForClass(TestModel);
57 | expect(TestModel.additionalSQLiteConfig.setup).toHaveBeenCalledWith();
58 | expect(queries.pop()).toBe('CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)');
59 | });
60 |
61 | it("should not fail if additional config is present, but setup is undefined", () => {
62 | delete TestModel.additionalSQLiteConfig.setup;
63 | this.m = new TestModel({id: 'local-6806434c-b0cd', body: 'hello world'});
64 | expect(() => setupQueriesForClass(TestModel)).not.toThrow();
65 | });
66 | });
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/example/img/settings.svg:
--------------------------------------------------------------------------------
1 | settings Created with Sketch.
--------------------------------------------------------------------------------
/src/query-subscription-pool.js:
--------------------------------------------------------------------------------
1 | /* eslint global-require: 0 */
2 | import QuerySubscription from './query-subscription';
3 |
4 | /**
5 | The QuerySubscriptionPool maintains a list of all of the query
6 | subscriptions in the app. In the future, this class will monitor performance,
7 | merge equivalent subscriptions, etc.
8 |
9 | @private
10 | */
11 | export default class QuerySubscriptionPool {
12 | constructor(database) {
13 | this._database = database;
14 | this._subscriptions = {};
15 | this._cleanupChecks = [];
16 | this._setup();
17 | }
18 |
19 | add(query, callback) {
20 | // TODO
21 | // if (NylasEnv.inDevMode()) {
22 | // callback._registrationPoint = this._formatRegistrationPoint((new Error).stack);
23 | // }
24 |
25 | const key = this._keyForQuery(query);
26 | let subscription = this._subscriptions[key];
27 | if (!subscription) {
28 | subscription = new QuerySubscription(query);
29 | this._subscriptions[key] = subscription;
30 | }
31 |
32 | subscription.addCallback(callback);
33 | return () => {
34 | subscription.removeCallback(callback);
35 | this._scheduleCleanupCheckForSubscription(key);
36 | };
37 | }
38 |
39 | addPrivateSubscription(key, subscription, callback) {
40 | this._subscriptions[key] = subscription;
41 | subscription.addCallback(callback);
42 | return () => {
43 | subscription.removeCallback(callback);
44 | this._scheduleCleanupCheckForSubscription(key);
45 | };
46 | }
47 |
48 | printSubscriptions() {
49 | // TODO
50 | // if (!NylasEnv.inDevMode()) {
51 | // console.log("printSubscriptions is only available in developer mode.");
52 | // return;
53 | // }
54 |
55 | for (const key of Object.keys(this._subscriptions)) {
56 | const subscription = this._subscriptions[key];
57 | console.log(key);
58 | console.group();
59 | for (const callback of subscription._callbacks) {
60 | console.log(`${callback._registrationPoint}`);
61 | }
62 | console.groupEnd();
63 | }
64 | }
65 |
66 | _scheduleCleanupCheckForSubscription(key) {
67 | // We unlisten / relisten to lots of subscriptions and setTimeout is actually
68 | // /not/ that fast. Create one timeout for all checks, not one for each.
69 | if (this._cleanupChecks.length === 0) {
70 | setTimeout(() => this._runCleanupChecks(), 1);
71 | }
72 | this._cleanupChecks.push(key);
73 | }
74 |
75 | _runCleanupChecks() {
76 | for (const key of this._cleanupChecks) {
77 | const subscription = this._subscriptions[key];
78 | if (subscription && (subscription.callbackCount() === 0)) {
79 | delete this._subscriptions[key];
80 | }
81 | }
82 | this._cleanupChecks = [];
83 | }
84 |
85 | _formatRegistrationPoint(stackString) {
86 | const stack = stackString.split('\n');
87 | let ii = 0;
88 | let seenRx = false;
89 | while (ii < stack.length) {
90 | const hasRx = stack[ii].indexOf('rx.lite') !== -1;
91 | seenRx = seenRx || hasRx;
92 | if (seenRx === true && !hasRx) {
93 | break;
94 | }
95 | ii += 1;
96 | }
97 |
98 | return stack.slice(ii, ii + 4).join('\n');
99 | }
100 |
101 | _keyForQuery(query) {
102 | return query.sql();
103 | }
104 |
105 | _setup() {
106 | this._database.listen(this._onChange);
107 | }
108 |
109 | _onChange = (record) => {
110 | for (const key of Object.keys(this._subscriptions)) {
111 | const subscription = this._subscriptions[key];
112 | subscription.applyChangeRecord(record);
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/model.js:
--------------------------------------------------------------------------------
1 | import Attributes from './attributes';
2 |
3 | function generateTempId() {
4 | const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
5 | return `local-${s4()}${s4()}-${s4()}`;
6 | }
7 | /**
8 | A base class for RxDB models that provides abstract support for JSON
9 | serialization and deserialization, and attribute-based matching.
10 |
11 | Your RxDB data classes should extend Model and extend it's attributes:
12 |
13 | - {AttributeString} id: The resolved canonical ID of the model used in the
14 | database and generally throughout the app.
15 |
16 | */
17 | export default class Model {
18 |
19 | static attributes = {
20 | id: Attributes.String({
21 | queryable: true,
22 | modelKey: 'id',
23 | }),
24 | }
25 |
26 | static naturalSortOrder() {
27 | return null;
28 | }
29 |
30 | constructor(values = {}) {
31 | for (const key of Object.keys(this.constructor.attributes)) {
32 | this[key] = values[key];
33 | }
34 | this.id = this.id || generateTempId();
35 | }
36 |
37 | clone() {
38 | return (new this.constructor()).fromJSON(this.toJSON())
39 | }
40 |
41 | // Public: Returns an {Array} of {Attribute} objects defined on the Model's constructor
42 | //
43 | attributes() {
44 | return Object.assign({}, this.constructor.attributes)
45 | }
46 |
47 | // Public: Inflates the model object from JSON, using the defined attributes to
48 | // guide type coercision.
49 | //
50 | // - `json` A plain Javascript {Object} with the JSON representation of the model.
51 | //
52 | // This method is chainable.
53 | //
54 | fromJSON(json) {
55 | // Note: The loop in this function has been optimized for the V8 'fast case'
56 | // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers
57 | //
58 | for (const key of Object.keys(this.constructor.attributes)) {
59 | const attr = this.constructor.attributes[key];
60 | const attrValue = json[attr.jsonKey];
61 | if (attrValue !== undefined) {
62 | this[key] = attr.fromJSON(attrValue);
63 | }
64 | }
65 | return this;
66 | }
67 |
68 | // Public: Deflates the model to a plain JSON object. Only attributes defined
69 | // on the model are included in the JSON.
70 | //
71 | // - `options` (optional) An {Object} with additional options. To skip joined
72 | // data attributes in the toJSON representation, pass the `joined:false`
73 | //
74 | // Returns an {Object} with the JSON representation of the model.
75 | //
76 | toJSON(options = {}) {
77 | const json = {}
78 | for (const key of Object.keys(this.constructor.attributes)) {
79 | const attr = this.constructor.attributes[key];
80 | const attrValue = this[key];
81 |
82 | if (attrValue === undefined) {
83 | continue;
84 | }
85 | if (attr instanceof Attributes.AttributeJoinedData && (options.joined === false)) {
86 | continue;
87 | }
88 | json[attr.jsonKey] = attr.toJSON(attrValue);
89 | }
90 | return json;
91 | }
92 |
93 | toString() {
94 | return JSON.stringify(this.toJSON());
95 | }
96 |
97 | // Public: Evaluates the model against one or more {Matcher} objects.
98 | //
99 | // - `criteria` An {Array} of {Matcher}s to run on the model.
100 | //
101 | // Returns true if the model matches the criteria.
102 | //
103 | matches(criteria) {
104 | if (!(criteria instanceof Array)) {
105 | return false;
106 | }
107 | for (const matcher of criteria) {
108 | if (!matcher.evaluate(this)) {
109 | return false;
110 | }
111 | }
112 | return true;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/attributes/attribute-collection.js:
--------------------------------------------------------------------------------
1 | import Attribute from './attribute';
2 | import Matcher from './matcher';
3 |
4 | /**
5 | Collection attributes provide basic support for one-to-many relationships.
6 | For example, Threads in N1 have a collection of Labels or Folders.
7 |
8 | When Collection attributes are marked as `queryable`, the RxDatabase
9 | automatically creates a join table and maintains it as you create, save,
10 | and delete models. When you call `persistModel`, entries are added to the
11 | join table associating the ID of the model with the IDs of models in the collection.
12 |
13 | Collection attributes have an additional clause builder, `contains`:
14 |
15 | ```coffee
16 | db.findAll(Thread).where([Thread.attributes.categories.contains('inbox')])
17 | ```
18 |
19 | This is equivalent to writing the following SQL:
20 |
21 | ```sql
22 | SELECT `Thread`.`data` FROM `Thread`
23 | INNER JOIN `ThreadLabel` AS `M1` ON `M1`.`id` = `Thread`.`id`
24 | WHERE `M1`.`value` = 'inbox'
25 | ORDER BY `Thread`.`last_message_received_timestamp` DESC
26 | ```
27 |
28 | The value of this attribute is always an array of other model objects.
29 |
30 | Section: Database
31 | */
32 | export default class AttributeCollection extends Attribute {
33 | constructor({modelKey, jsonKey, itemClass, joinOnField, joinQueryableBy, queryable}) {
34 | super({modelKey, jsonKey, queryable});
35 | this.ItemClass = this.itemClass = itemClass;
36 | this.joinOnField = joinOnField;
37 | this.joinQueryableBy = joinQueryableBy || [];
38 | }
39 |
40 | toJSON(vals) {
41 | if (!vals) {
42 | return [];
43 | }
44 |
45 | if (!(vals instanceof Array)) {
46 | throw new Error(`AttributeCollection::toJSON: ${this.modelKey} is not an array.`);
47 | }
48 |
49 | const json = []
50 | for (const val of vals) {
51 | if (!(val instanceof this.ItemClass)) {
52 | throw new Error(`AttributeCollection::toJSON: Value \`${val}\` in ${this.modelKey} is not an ${this.ItemClass.name}`);
53 | }
54 | if (val.toJSON !== undefined) {
55 | json.push(val.toJSON());
56 | } else {
57 | json.push(val);
58 | }
59 | }
60 | return json;
61 | }
62 |
63 | fromJSON(json) {
64 | if (!json || !(json instanceof Array)) {
65 | return [];
66 | }
67 | const objs = [];
68 |
69 | for (const objJSON of json) {
70 | // Note: It's possible for a malformed API request to return an array
71 | // of null values. N1 is tolerant to this type of error, but shouldn't
72 | // happen on the API end.
73 | if (!objJSON) {
74 | continue;
75 | }
76 |
77 | if (this.ItemClass.prototype.fromJSON) {
78 | const obj = new this.ItemClass();
79 | // Important: if no ids are in the JSON, don't make them up
80 | // randomly. This causes an object to be "different" each time it's
81 | // de-serialized even if it's actually the same, makes React
82 | // components re-render!
83 | obj.id = undefined;
84 | obj.fromJSON(objJSON);
85 | objs.push(obj);
86 | } else {
87 | const obj = new this.ItemClass(objJSON);
88 | obj.id = undefined;
89 | objs.push(obj);
90 | }
91 | }
92 | return objs;
93 | }
94 |
95 | /**
96 | @returns {Matcher} - Matcher for objects containing the provided value.
97 | */
98 | contains(val) {
99 | this._assertPresentAndQueryable('contains', val);
100 | return new Matcher(this, 'contains', val);
101 | }
102 |
103 | containsAny(vals) {
104 | this._assertPresentAndQueryable('contains', vals);
105 | return new Matcher(this, 'containsAny', vals);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/example/src/importers/wikipedia.js:
--------------------------------------------------------------------------------
1 | import {remote} from 'electron';
2 | import {EventEmitter} from 'events';
3 | import request from 'request';
4 | import Note from '../models/note';
5 |
6 | const StorageKey = "lastWikipediaKey3";
7 |
8 | export default class WikipediaImporter extends EventEmitter {
9 | constructor() {
10 | super();
11 | this.running = false;
12 | }
13 |
14 | run() {
15 | this.running = true;
16 | this.count = 0;
17 | this._runSingleIteration();
18 | this.emit('updated');
19 | }
20 |
21 | cancel() {
22 | this.running = false;
23 | this.emit('updated');
24 | }
25 |
26 | processResponse(error, response, body) {
27 | if (error) {
28 | return {error};
29 | }
30 | if (response.statusCode !== 200) {
31 | return {error: new Error(`Got status code ${response.statusCode}`)};
32 | }
33 |
34 | let json = null;
35 | try {
36 | json = JSON.parse(body);
37 | } catch (error) {
38 | console.log("Got invalid JSON? Body: ");
39 | console.log(body);
40 | return {error};
41 | }
42 | return {json};
43 | }
44 |
45 | fetchArticleIds(callback) {
46 | const lastArticleKey = localStorage.getItem(StorageKey) || "Apple";
47 |
48 | console.log(`Fetching articles from ${lastArticleKey}`);
49 | request(`https://en.wikipedia.org/w/api.php?format=json&action=query&list=allpages&apfrom=${lastArticleKey}&apminsize=50000`, (err, response, body) => {
50 | const {error, json} = this.processResponse(err, response, body);
51 | if (error) {
52 | return callback(error);
53 | }
54 |
55 | if (json.query && json.query.allpages) {
56 | const ids = json.query.allpages.map(p => p.pageid);
57 | this.fetchArticleExtracts(ids, '', (err) => {
58 | if (err) {
59 | return callback(err);
60 | }
61 |
62 | localStorage.setItem(StorageKey, json.continue.apcontinue);
63 | return callback(null);
64 | });
65 | }
66 | });
67 | }
68 |
69 | fetchArticleExtracts(ids, excontinue, callback) {
70 | request(`https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&pageids=${ids.join('|')}&excontinue=${excontinue}`, (err, response, body) => {
71 | const {error, json} = this.processResponse(err, response, body);
72 | if (error) {
73 | return callback(error);
74 | }
75 |
76 | if (!json.query || !json.query.pages) {
77 | return callback(new Error("JSON did not contain query or query.pages"));
78 | }
79 | const notes = [];
80 | for (const key of Object.keys(json.query.pages)) {
81 | const article = json.query.pages[key];
82 | const {title, extract} = article;
83 | if (extract) {
84 | notes.push(new Note({
85 | name: title,
86 | content: extract,
87 | }))
88 | }
89 | }
90 |
91 | this.count += notes.length;
92 | this.emit('updated');
93 |
94 | window.Database.inTransaction((t) => {
95 | return t.persistModels(notes)
96 | });
97 |
98 | if (json.continue && json.continue.excontinue) {
99 | this.fetchArticleExtracts(ids, json.continue.excontinue, callback)
100 | } else {
101 | callback(null);
102 | }
103 | })
104 | }
105 |
106 | _runSingleIteration() {
107 | this.fetchArticleIds((err) => {
108 | if (err) {
109 | console.error(err);
110 | remote.dialog.showErrorBox("Wikipedia Crawler Stopped", err.stack);
111 |
112 | this.running = false;
113 | this.emit('updated');
114 | return;
115 | }
116 | if (this.running) {
117 | this._runSingleIteration();
118 | }
119 | });
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/mutable-query-result-set.js:
--------------------------------------------------------------------------------
1 | import QueryResultSet from './query-result-set';
2 | import AttributeJoinedData from './attributes/attribute-joined-data';
3 |
4 | // TODO: Make mutator methods QueryResultSet.join(), QueryResultSet.clip...
5 | export default class MutableQueryResultSet extends QueryResultSet {
6 |
7 | immutableClone() {
8 | const set = new QueryResultSet({
9 | _ids: [].concat(this._ids),
10 | _modelsHash: Object.assign({}, this._modelsHash),
11 | _query: this._query,
12 | _offset: this._offset,
13 | });
14 | Object.freeze(set._ids);
15 | Object.freeze(set._modelsHash);
16 | return set;
17 | }
18 |
19 | clipToRange(range) {
20 | if (range.isInfinite()) {
21 | return;
22 | }
23 | if (range.offset > this._offset) {
24 | this._ids = this._ids.slice(range.offset - this._offset);
25 | this._offset = range.offset;
26 | }
27 |
28 | const rangeEnd = range.offset + range.limit;
29 | const selfEnd = this._offset + this._ids.length;
30 | if (rangeEnd < selfEnd) {
31 | this._ids.length = Math.max(0, rangeEnd - this._offset);
32 | }
33 |
34 | const models = this.models();
35 | this._modelsHash = {};
36 | this._idToIndexHash = null;
37 | models.forEach((m) => this.updateModel(m));
38 | }
39 |
40 | addModelsInRange(rangeModels, range) {
41 | this.addIdsInRange(rangeModels.map(m => m.id), range);
42 | rangeModels.forEach((m) => this.updateModel(m));
43 | }
44 |
45 | addIdsInRange(rangeIds, range) {
46 | if ((this._offset === null) || range.isInfinite()) {
47 | this._ids = rangeIds;
48 | this._idToIndexHash = null;
49 | this._offset = range.offset;
50 | } else {
51 | const currentEnd = this._offset + this._ids.length;
52 | const rangeIdsEnd = range.offset + rangeIds.length;
53 |
54 | if (rangeIdsEnd < this._offset) {
55 | throw new Error(`addIdsInRange: You can only add adjacent values (${rangeIdsEnd} < ${this._offset})`)
56 | }
57 | if (range.offset > currentEnd) {
58 | throw new Error(`addIdsInRange: You can only add adjacent values (${range.offset} > ${currentEnd})`);
59 | }
60 |
61 | let existingBefore = []
62 | if (range.offset > this._offset) {
63 | existingBefore = this._ids.slice(0, range.offset - this._offset);
64 | }
65 |
66 | let existingAfter = []
67 | if ((rangeIds.length === range.limit) && (currentEnd > rangeIdsEnd)) {
68 | existingAfter = this._ids.slice(rangeIdsEnd - this._offset);
69 | }
70 |
71 | this._ids = [].concat(existingBefore, rangeIds, existingAfter);
72 | this._idToIndexHash = null;
73 | this._offset = Math.min(this._offset, range.offset);
74 | }
75 | }
76 |
77 | updateModel(item) {
78 | if (!item) {
79 | return;
80 | }
81 |
82 | // Sometimes the new copy of `item` doesn't contain the joined data present
83 | // in the old one, since it's not provided by default and may not have changed.
84 | // Make sure we never drop joined data by pulling it over.
85 | const existing = this._modelsHash[item.id];
86 | if (existing) {
87 | const attrs = existing.constructor.attributes
88 | for (const key of Object.keys(attrs)) {
89 | const attr = attrs[key];
90 | if ((attr instanceof AttributeJoinedData) && (item[attr.modelKey] === undefined)) {
91 | item[attr.modelKey] = existing[attr.modelKey];
92 | }
93 | }
94 | }
95 | this._modelsHash[item.id] = item;
96 | this._idToIndexHash = null;
97 | }
98 |
99 | removeModelAtOffset(item, offset) {
100 | const idx = offset - this._offset;
101 | delete this._modelsHash[item.id];
102 | this._ids.splice(idx, 1);
103 | this._idToIndexHash = null;
104 | }
105 |
106 | setQuery(query) {
107 | this._query = query.clone();
108 | this._query.finalize();
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/spec/fixtures/thread.js:
--------------------------------------------------------------------------------
1 | import Category from './category'
2 | import Attributes from '../../src/attributes'
3 | import Model from '../../src/model'
4 |
5 | export default class Thread extends Model {
6 |
7 | static attributes = Object.assign({}, Model.attributes, {
8 | accountId: Attributes.String({
9 | modelKey: 'accountId',
10 | jsonKey: 'account_id',
11 | queryable: true,
12 | }),
13 |
14 | snippet: Attributes.String({
15 | modelKey: 'snippet',
16 | }),
17 |
18 | subject: Attributes.String({
19 | queryable: true,
20 | modelKey: 'subject',
21 | }),
22 |
23 | unread: Attributes.Boolean({
24 | queryable: true,
25 | modelKey: 'unread',
26 | }),
27 |
28 | starred: Attributes.Boolean({
29 | queryable: true,
30 | modelKey: 'starred',
31 | }),
32 |
33 | version: Attributes.Number({
34 | queryable: true,
35 | modelKey: 'version',
36 | }),
37 |
38 | categories: Attributes.Collection({
39 | queryable: true,
40 | modelKey: 'categories',
41 | joinOnField: 'id',
42 | joinQueryableBy: ['inAllMail', 'lastMessageReceivedTimestamp', 'lastMessageSentTimestamp', 'unread'],
43 | itemClass: Category,
44 | }),
45 |
46 | categoriesType: Attributes.String({
47 | modelKey: 'categoriesType',
48 | }),
49 |
50 | hasAttachments: Attributes.Boolean({
51 | modelKey: 'has_attachments',
52 | }),
53 |
54 | lastMessageReceivedTimestamp: Attributes.Number({
55 | queryable: true,
56 | modelKey: 'lastMessageReceivedTimestamp',
57 | jsonKey: 'last_message_received_timestamp',
58 | }),
59 |
60 | lastMessageSentTimestamp: Attributes.Number({
61 | queryable: true,
62 | modelKey: 'lastMessageSentTimestamp',
63 | jsonKey: 'last_message_sent_timestamp',
64 | }),
65 |
66 | inAllMail: Attributes.Boolean({
67 | queryable: true,
68 | modelKey: 'inAllMail',
69 | jsonKey: 'in_all_mail',
70 | }),
71 | })
72 |
73 | static naturalSortOrder = () => {
74 | return Thread.attributes.lastMessageReceivedTimestamp.descending()
75 | }
76 |
77 | static additionalSQLiteConfig = {
78 | setup: () => [
79 | // ThreadCounts
80 | 'CREATE TABLE IF NOT EXISTS `ThreadCounts` (`category_id` TEXT PRIMARY KEY, `unread` INTEGER, `total` INTEGER)',
81 | 'CREATE UNIQUE INDEX IF NOT EXISTS ThreadCountsIndex ON `ThreadCounts` (category_id DESC)',
82 |
83 | // ThreadContact
84 | 'CREATE INDEX IF NOT EXISTS ThreadContactDateIndex ON `ThreadContact` (last_message_received_timestamp DESC, value, id)',
85 |
86 | // ThreadCategory
87 | 'CREATE INDEX IF NOT EXISTS ThreadListCategoryIndex ON `ThreadCategory` (last_message_received_timestamp DESC, value, in_all_mail, unread, id)',
88 | 'CREATE INDEX IF NOT EXISTS ThreadListCategorySentIndex ON `ThreadCategory` (last_message_sent_timestamp DESC, value, in_all_mail, unread, id)',
89 |
90 | // Thread: General index
91 | 'CREATE INDEX IF NOT EXISTS ThreadDateIndex ON `Thread` (last_message_received_timestamp DESC)',
92 | 'CREATE INDEX IF NOT EXISTS ThreadIdIndex ON `Thread` (id)',
93 |
94 | // Thread: Partial indexes for specific views
95 | 'CREATE INDEX IF NOT EXISTS ThreadUnreadIndex ON `Thread` (account_id, last_message_received_timestamp DESC) WHERE unread = 1 AND in_all_mail = 1',
96 | 'CREATE INDEX IF NOT EXISTS ThreadUnifiedUnreadIndex ON `Thread` (last_message_received_timestamp DESC) WHERE unread = 1 AND in_all_mail = 1',
97 |
98 | 'DROP INDEX IF EXISTS `Thread`.ThreadStarIndex',
99 | 'CREATE INDEX IF NOT EXISTS ThreadStarredIndex ON `Thread` (account_id, last_message_received_timestamp DESC) WHERE starred = 1 AND in_all_mail = 1',
100 | 'CREATE INDEX IF NOT EXISTS ThreadUnifiedStarredIndex ON `Thread` (last_message_received_timestamp DESC) WHERE starred = 1 AND in_all_mail = 1',
101 | ],
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/model-registry.js:
--------------------------------------------------------------------------------
1 | import Model from './model';
2 |
3 | /**
4 | This keeps track of constructors so we know how to inflate
5 | serialized objects.
6 |
7 | We map constructor string names with factory functions that will return
8 | the actual constructor itself.
9 |
10 | The reason we have an extra function call to return a constructor is so
11 | we don't need to `require` all constructors at once on load. We are
12 | wasting a very large amount of time on bootup requiring files that may
13 | never be used or only used way down the line.
14 |
15 | If 3rd party packages want to register new inflatable models, they can
16 | use `register` and pass the constructor generator along with the name.
17 |
18 | Note that there is one registry per window.
19 |
20 | @private
21 | */
22 | export default class ModelRegistry {
23 | constructor() {
24 | this._classMap = {};
25 |
26 | const registry = this;
27 |
28 | this.JSONReviver = function JSONReviver(k, v) {
29 | const type = v ? v.__constructorName : null;
30 | if (!type) {
31 | return v;
32 | }
33 |
34 | if (registry.has(type)) {
35 | return registry.deserialize(type, v);
36 | }
37 |
38 | return v;
39 | }
40 |
41 | this.JSONReplacer = function JSONReplacer(k, v) {
42 | if (v instanceof Object) {
43 | const type = this[k].constructor.name;
44 | if (registry.has(type)) {
45 | v.__constructorName = type;
46 | }
47 | }
48 | return v;
49 | }
50 | }
51 |
52 | /**
53 | @param {String} name - The name of the class
54 | @returns {Model} - The Model subclass with the given name.
55 | */
56 | get(name) {
57 | return this._classMap[name].call(null);
58 | }
59 |
60 | getAllConstructors() {
61 | return Object.keys(this._classMap).map((name) => this.get(name))
62 | }
63 |
64 | /**
65 | @param {String} name - The name of the class.
66 | @returns {Boolean} - True if a class with the given name has been registered.
67 | */
68 | has(name) {
69 | return !!this._classMap[name];
70 | }
71 |
72 | /**
73 | Add a Model class so that it can be used with an RxDB database.
74 |
75 | @param {Model} klass - The subclass of Model to register
76 | */
77 | register(klass) {
78 | Object.keys(klass.searchIndexes || {}).forEach((name) => {
79 | klass.searchIndexes[name].name = name;
80 | klass.searchIndexes[name].klass = klass;
81 | })
82 |
83 | this.registerDeferred({
84 | name: klass.constructor.name,
85 | resolver: () => klass,
86 | })
87 | }
88 |
89 | /**
90 | Add a Model class without requiring it immediately. Provide the name of
91 | the class and a resolver function that returns it upon request.
92 |
93 | The resolver will not be invoked until an instance of the class is needed
94 | for the first time, which can improve load time.
95 |
96 | @param {String} name - The class name
97 | @param {Function} resolver - A function that will return the class. Generally,
98 | this function calls `require`.
99 | */
100 | registerDeferred({name, resolver}) {
101 | this._classMap[name] = resolver;
102 | }
103 |
104 | /**
105 | Remove a Model class.
106 | */
107 | unregister(name) {
108 | delete this._classMap[name];
109 | }
110 |
111 | /**
112 | @private
113 | */
114 | deserialize(name, dataJSON) {
115 | let data = dataJSON;
116 | if (typeof data === "string") {
117 | data = JSON.parse(dataJSON);
118 | }
119 |
120 | const constructor = this.get(name);
121 |
122 | if (typeof constructor !== "function") {
123 | throw new Error(`ModelRegistry: Unsure of how to inflate ${JSON.stringify(data)}. Your constructor factory must return a class constructor.`);
124 | }
125 |
126 | const object = new constructor();
127 | if (!(object instanceof Model)) {
128 | throw new Error(`ModelRegistry: ${name} is not a subclass of RxDB.Model.`);
129 | }
130 | object.fromJSON(data);
131 |
132 | return object;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/docs/script/search.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | var searchIndex = window.esdocSearchIndex;
3 | var searchBox = document.querySelector('.search-box');
4 | var input = document.querySelector('.search-input');
5 | var result = document.querySelector('.search-result');
6 | var selectedIndex = -1;
7 | var prevText;
8 |
9 | // active search box and focus when mouse enter on search box.
10 | searchBox.addEventListener('mouseenter', function(){
11 | searchBox.classList.add('active');
12 | input.focus();
13 | });
14 |
15 | // search with text when key is upped.
16 | input.addEventListener('keyup', function(ev){
17 | var text = ev.target.value.toLowerCase();
18 | if (!text) {
19 | result.style.display = 'none';
20 | result.innerHTML = '';
21 | return;
22 | }
23 |
24 | if (text === prevText) return;
25 | prevText = text;
26 |
27 | var html = {class: [], method: [], member: [], function: [], variable: [], typedef: [], external: [], file: [], test: [], testFile: []};
28 | var len = searchIndex.length;
29 | var kind;
30 | for (var i = 0; i < len; i++) {
31 | var pair = searchIndex[i];
32 | if (pair[0].indexOf(text) !== -1) {
33 | kind = pair[3];
34 | html[kind].push('' + pair[2] + ' ');
35 | }
36 | }
37 |
38 | var innerHTML = '';
39 | for (kind in html) {
40 | var list = html[kind];
41 | if (!list.length) continue;
42 | innerHTML += '' + kind + ' \n' + list.join('\n');
43 | }
44 | result.innerHTML = innerHTML;
45 | if (innerHTML) result.style.display = 'block';
46 | selectedIndex = -1;
47 | });
48 |
49 | // down, up and enter key are pressed, select search result.
50 | input.addEventListener('keydown', function(ev){
51 | if (ev.keyCode === 40) {
52 | // arrow down
53 | var current = result.children[selectedIndex];
54 | var selected = result.children[selectedIndex + 1];
55 | if (selected && selected.classList.contains('search-separator')) {
56 | var selected = result.children[selectedIndex + 2];
57 | selectedIndex++;
58 | }
59 |
60 | if (selected) {
61 | if (current) current.classList.remove('selected');
62 | selectedIndex++;
63 | selected.classList.add('selected');
64 | }
65 | } else if (ev.keyCode === 38) {
66 | // arrow up
67 | var current = result.children[selectedIndex];
68 | var selected = result.children[selectedIndex - 1];
69 | if (selected && selected.classList.contains('search-separator')) {
70 | var selected = result.children[selectedIndex - 2];
71 | selectedIndex--;
72 | }
73 |
74 | if (selected) {
75 | if (current) current.classList.remove('selected');
76 | selectedIndex--;
77 | selected.classList.add('selected');
78 | }
79 | } else if (ev.keyCode === 13) {
80 | // enter
81 | var current = result.children[selectedIndex];
82 | if (current) {
83 | var link = current.querySelector('a');
84 | if (link) location.href = link.href;
85 | }
86 | } else {
87 | return;
88 | }
89 |
90 | ev.preventDefault();
91 | });
92 |
93 | // select search result when search result is mouse over.
94 | result.addEventListener('mousemove', function(ev){
95 | var current = result.children[selectedIndex];
96 | if (current) current.classList.remove('selected');
97 |
98 | var li = ev.target;
99 | while (li) {
100 | if (li.nodeName === 'LI') break;
101 | li = li.parentElement;
102 | }
103 |
104 | if (li) {
105 | selectedIndex = Array.prototype.indexOf.call(result.children, li);
106 | li.classList.add('selected');
107 | }
108 | });
109 |
110 | // clear search result when body is clicked.
111 | document.body.addEventListener('click', function(ev){
112 | selectedIndex = -1;
113 | result.style.display = 'none';
114 | result.innerHTML = '';
115 | });
116 |
117 | })();
118 |
--------------------------------------------------------------------------------
/spec/query-range-spec.js:
--------------------------------------------------------------------------------
1 | import QueryRange from '../src/query-range';
2 |
3 | describe("QueryRange", function QueryRangeSpecs() {
4 | describe("@infinite", () =>
5 | it("should return a query range with a null limit and offset", () => {
6 | const infinite = QueryRange.infinite();
7 | expect(infinite.limit).toBe(null);
8 | expect(infinite.offset).toBe(null);
9 | })
10 |
11 | );
12 |
13 | describe("@rangesBySubtracting", () => {
14 | it("should throw an exception if either range is infinite", () => {
15 | const infinite = QueryRange.infinite();
16 |
17 | expect(() =>
18 | QueryRange.rangesBySubtracting(infinite, new QueryRange({offset: 0, limit: 10}))
19 | ).toThrow();
20 |
21 | expect(() =>
22 | QueryRange.rangesBySubtracting(new QueryRange({offset: 0, limit: 10}), infinite)
23 | ).toThrow();
24 | });
25 |
26 | it("should return one or more ranges created by punching the provided range", () => {
27 | const test = ({a, b, result}) => expect(QueryRange.rangesBySubtracting(a, b)).toEqual(result);
28 | test({
29 | a: new QueryRange({offset: 0, limit: 10}),
30 | b: new QueryRange({offset: 3, limit: 3}),
31 | result: [new QueryRange({offset: 0, limit: 3}), new QueryRange({offset: 6, limit: 4})]});
32 |
33 | test({
34 | a: new QueryRange({offset: 0, limit: 10}),
35 | b: new QueryRange({offset: 3, limit: 10}),
36 | result: [new QueryRange({offset: 0, limit: 3})]});
37 |
38 | test({
39 | a: new QueryRange({offset: 0, limit: 10}),
40 | b: new QueryRange({offset: 0, limit: 10}),
41 | result: []});
42 |
43 | test({
44 | a: new QueryRange({offset: 5, limit: 10}),
45 | b: new QueryRange({offset: 0, limit: 4}),
46 | result: [new QueryRange({offset: 5, limit: 10})]});
47 |
48 | test({
49 | a: new QueryRange({offset: 5, limit: 10}),
50 | b: new QueryRange({offset: 0, limit: 8}),
51 | result: [new QueryRange({offset: 8, limit: 7})]});
52 | });
53 | });
54 |
55 | describe("isInfinite", () =>
56 | it("should return true for an infinite range, false otherwise", () => {
57 | const infinite = QueryRange.infinite();
58 | expect(infinite.isInfinite()).toBe(true);
59 | expect(new QueryRange({offset: 0, limit: 4}).isInfinite()).toBe(false);
60 | })
61 | );
62 |
63 | describe("start", () =>
64 | it("should be an alias for offset", () =>
65 | expect((new QueryRange({offset: 3, limit: 4})).start).toBe(3)
66 | )
67 | );
68 |
69 | describe("end", () =>
70 | it("should be offset + limit", () =>
71 | expect((new QueryRange({offset: 3, limit: 4})).end).toBe(7)
72 | )
73 | );
74 |
75 | describe("isContiguousWith", () => {
76 | it("should return true if either range is infinite", () => {
77 | const a = new QueryRange({offset: 3, limit: 4});
78 | expect(a.isContiguousWith(QueryRange.infinite())).toBe(true);
79 | expect(QueryRange.infinite().isContiguousWith(a)).toBe(true);
80 | });
81 |
82 | it("should return true if the ranges intersect or touch, false otherwise", () => {
83 | const a = new QueryRange({offset: 3, limit: 4});
84 | const b = new QueryRange({offset: 0, limit: 2});
85 | const c = new QueryRange({offset: 0, limit: 3});
86 | const d = new QueryRange({offset: 7, limit: 10});
87 | const e = new QueryRange({offset: 8, limit: 10});
88 |
89 | // True
90 |
91 | expect(a.isContiguousWith(d)).toBe(true);
92 | expect(d.isContiguousWith(a)).toBe(true);
93 |
94 | expect(a.isContiguousWith(c)).toBe(true);
95 | expect(c.isContiguousWith(a)).toBe(true);
96 |
97 | // False
98 |
99 | expect(a.isContiguousWith(b)).toBe(false);
100 | expect(b.isContiguousWith(a)).toBe(false);
101 |
102 | expect(a.isContiguousWith(e)).toBe(false);
103 | expect(e.isContiguousWith(a)).toBe(false);
104 |
105 | expect(b.isContiguousWith(e)).toBe(false);
106 | expect(e.isContiguousWith(b)).toBe(false);
107 | });
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/example/renderer.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | }
6 |
7 | body {
8 | font-family: -apple-system, 'Helvetica Neue', Helvetica, sans-serif;
9 | }
10 |
11 | header {
12 | position: absolute;
13 | width: 500px;
14 | height: 250px;
15 | top: 50%;
16 | left: 50%;
17 | margin-top: -125px;
18 | margin-left: -250px;
19 | text-align: center;
20 | }
21 |
22 | header h1 {
23 | font-size: 60px;
24 | font-weight: 100;
25 | margin: 0;
26 | padding: 0;
27 | }
28 |
29 | div, h2 {
30 | outline: none;
31 | }
32 |
33 | .actions {
34 | position:absolute;
35 | right: 10px;
36 | top: 10px;
37 | text-align: right;
38 | }
39 | .actions button {
40 | border: 0;
41 | background-color: transparent;
42 | line-height: 20px;
43 | width: 32px;
44 | height: 32px;
45 | border-radius: 4px;
46 | border: 1px solid #ccc;
47 | text-align: center;
48 | margin-left: 5px;
49 | outline: none;
50 | }
51 | .actions button.delete {
52 | background:url(./img/delete.svg) center;
53 | }
54 | .actions button.starred {
55 | background:url(./img/favorite.svg) center;
56 | }
57 | .actions button.starred.is-true {
58 | background:url(./img/favorite-true.svg) center;
59 | }
60 | .actions button:hover {
61 | background-color: rgba(0,0,0,0.05);
62 | }
63 |
64 | .container {
65 | display: flex;
66 | position: absolute;
67 | height: 100%;
68 | width: 100%;
69 | overflow: hidden;
70 | }
71 | .detail {
72 | padding: 4px 10px;
73 | display: flex;
74 | flex-direction: column;
75 | flex: 2;
76 | }
77 | .detail .empty {
78 | padding: 40vh 05px;
79 | text-align: center;
80 | color: #ccc;
81 | }
82 | .detail h2 {
83 | margin: 8px 0;
84 | margin-right: 80px;
85 | }
86 | .detail h2:hover {
87 | background-color: rgba(0,0,0,0.05);
88 | }
89 | .detail .content {
90 | flex: 1;
91 | overflow-x: hidden;
92 | overflow-y: scroll;
93 | line-height: 1.45em;
94 | }
95 | .sidebar {
96 | flex: 1;
97 | display: flex;
98 | flex-direction: column;
99 | font-size: 13px;
100 | background-color: #313a3e;
101 | color: #eee;
102 | box-shadow: inset -2px 0 2px 0 rgba(0,0,0,0.2);
103 | min-width: 130px;
104 | }
105 |
106 | .sidebar .search-results {
107 | overflow-x: hidden;
108 | overflow-y: scroll;
109 | padding-top: 5px;
110 | flex: 1;
111 | }
112 | .sidebar .recents {
113 | background-color: rgba(0, 0, 0, 0.2);
114 | }
115 | .sidebar .recents .heading {
116 | padding: 5px 10px;
117 | color: rgba(255,255,255,0.3);
118 | text-transform: uppercase;
119 | font-weight: 400;
120 | font-size: 11px;
121 | }
122 |
123 | .sidebar .search {
124 | flex-grow: 0;
125 | }
126 |
127 | .sidebar .search input {
128 | width: 100%;
129 | outline: 0;
130 | font-size: 13px;
131 | padding: 5px 10px;
132 | padding-top: 25px;
133 | background-color: transparent;
134 | border: 0;
135 | color: #eee;
136 | border-bottom: 2px dotted rgba(255, 255, 255, 0.2);
137 | }
138 | .sidebar .item {
139 | padding: 5px 10px;
140 | -webkit-user-select: none;
141 | }
142 | .sidebar .item:hover {
143 | cursor: pointer;
144 | background-color: rgba(0,0,0,0.1);
145 | }
146 | .sidebar .item.starred {
147 | background:url(./img/favorite-true.svg) center right no-repeat;
148 | }
149 |
150 | .sidebar .item.selected,
151 | .sidebar .item.selected:hover {
152 | background-color: gray;
153 | -webkit-user-select: text;
154 | }
155 |
156 | .floating {
157 | position: absolute;
158 | right: 10px;
159 | bottom: 10px;
160 | }
161 | .floating button {
162 | color: white;
163 | border-radius: 50%;
164 | width: 40px;
165 | height: 40px;
166 | display: inline-block;
167 | outline: 0;
168 | margin-left: 8px;
169 | vertical-align: top;
170 | }
171 |
172 | .floating button.add {
173 | background: rgb(100,200,255) url(./img/add.svg);
174 | border: 1px solid rgba(0,0,0,0.3);
175 | }
176 | .floating button.wikipedia {
177 | font-size: 20px;
178 | line-height: 35px;
179 | font-family: serif;
180 | color: #111;
181 | background: rgb(226, 226, 226);
182 | border: 1px solid rgba(0,0,0,0.3);
183 | }
184 | .floating button.wikipedia.running {
185 | font-family: sans-serif;
186 | background: linear-gradient(270deg, #ffcccc, #ccffcc);
187 | background-size: 400% 400%;
188 | -webkit-animation: ImportRunning 1.5s ease infinite;
189 | }
190 |
191 | @-webkit-keyframes ImportRunning {
192 | 0%{background-position:0% 50%}
193 | 50%{background-position:100% 50%}
194 | 100%{background-position:0% 50%}
195 | }
196 |
--------------------------------------------------------------------------------
/src/query-result-set.js:
--------------------------------------------------------------------------------
1 | import QueryRange from './query-range';
2 |
3 | /**
4 | Instances of QueryResultSet hold a set of models retrieved from the database
5 | for a given query and offset.
6 |
7 | Complete vs Incomplete:
8 |
9 | QueryResultSet keeps an array of item ids and a lookup table of models.
10 | The lookup table may be incomplete if the QuerySubscription isn't finished
11 | preparing results. You can use `isComplete` to determine whether the set
12 | has every model.
13 |
14 | Offset vs Index:
15 |
16 | To avoid confusion, "index" (used within the implementation) refers to an item's
17 | position in an array, and "offset" refers to it's position in the query
18 | result set. For example, an item might be at index 20 in the _ids array, but
19 | at offset 120 in the result.
20 | */
21 | export default class QueryResultSet {
22 |
23 | static setByApplyingModels(set, models) {
24 | if (models instanceof Array) {
25 | throw new Error("setByApplyingModels: A hash of models is required.");
26 | }
27 | const out = set.clone();
28 | out._modelsHash = models;
29 | out._idToIndexHash = null;
30 | return out;
31 | }
32 |
33 | constructor(other = {}) {
34 | this._offset = (other._offset !== undefined) ? other._offset : null;
35 | this._query = (other._query !== undefined) ? other._query : null;
36 | this._idToIndexHash = (other._idToIndexHash !== undefined) ? other._idToIndexHash : null;
37 | // Clone, since the others may be frozen
38 | this._modelsHash = Object.assign({}, other._modelsHash || {})
39 | this._ids = [].concat(other._ids || []);
40 | }
41 |
42 | clone() {
43 | return new this.constructor({
44 | _ids: [].concat(this._ids),
45 | _modelsHash: Object.assign({}, this._modelsHash),
46 | _idToIndexHash: Object.assign({}, this._idToIndexHash),
47 | _query: this._query,
48 | _offset: this._offset,
49 | });
50 | }
51 |
52 | /**
53 | @returns {Boolean} - True if every model in the result set is available, false
54 | if part of the set is still being loaded. (Usually following range changes.)
55 | */
56 | isComplete() {
57 | return this._ids.every((id) => this._modelsHash[id]);
58 | }
59 |
60 | /**
61 | @returns {QueryRange} - The represented range.
62 | */
63 | range() {
64 | return new QueryRange({offset: this._offset, limit: this._ids.length});
65 | }
66 |
67 | /**
68 | @returns {Query} - The represented query.
69 | */
70 | query() {
71 | return this._query;
72 | }
73 |
74 | /**
75 | @returns {QueryRange} - The number of items in the represented range. Note
76 | that a range (`LIMIT 10 OFFSET 0`) may return fewer than the maximum number
77 | of items if none match the query.
78 | */
79 | count() {
80 | return this._ids.length;
81 | }
82 |
83 | /**
84 | @returns {Boolean} - True if the result set is empty, false otherwise.
85 | */
86 | empty() {
87 | return this.count() === 0;
88 | }
89 |
90 | /**
91 | @returns {Array} - the model IDs in the represented result.
92 | */
93 | ids() {
94 | return this._ids;
95 | }
96 |
97 | /**
98 | @param {Number} offset - The desired offset.
99 | @returns {Array} - the model ID available at the requested offset, or undefined.
100 | */
101 | idAtOffset(offset) {
102 | return this._ids[offset - this._offset];
103 | }
104 |
105 | /**
106 | @returns {Array} - An array of model objects. If the result is not yet complete,
107 | this array may contain `undefined` values.
108 | */
109 | models() {
110 | return this._ids.map((id) => this._modelsHash[id]);
111 | }
112 |
113 | /**
114 | @protected
115 | */
116 | modelCacheCount() {
117 | return Object.keys(this._modelsHash).length;
118 | }
119 |
120 | /**
121 | @param {Number} offset - The desired offset.
122 | @returns {Model} - the model at the requested offset.
123 | */
124 | modelAtOffset(offset) {
125 | if (!Number.isInteger(offset)) {
126 | throw new Error("QueryResultSet.modelAtOffset() takes a numeric index. Maybe you meant modelWithId()?");
127 | }
128 | return this._modelsHash[this._ids[offset - this._offset]];
129 | }
130 |
131 | /**
132 | @param {String} id - The desired ID.
133 | @returns {Model} - the model with the requested ID, or undefined.
134 | */
135 | modelWithId(id) {
136 | return this._modelsHash[id];
137 | }
138 |
139 | _buildIdToIndexHash() {
140 | this._idToIndexHash = {}
141 | this._ids.forEach((id, idx) => {
142 | this._idToIndexHash[id] = idx;
143 | const model = this._modelsHash[id];
144 | if (model) {
145 | this._idToIndexHash[model.id] = idx;
146 | }
147 | });
148 | }
149 |
150 | /**
151 | @param {String} id - The desired ID.
152 | @returns {Number} - the offset of `ID`, relative to all of the query results.
153 | */
154 | offsetOfId(id) {
155 | if (this._idToIndexHash === null) {
156 | this._buildIdToIndexHash();
157 | }
158 |
159 | if (this._idToIndexHash[id] === undefined) {
160 | return -1;
161 | }
162 | return this._idToIndexHash[id] + this._offset
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/search-indexes/search-index-fts5.js:
--------------------------------------------------------------------------------
1 | const {Matcher} = require('../attributes');
2 | const {singleQuoteEscapeSequence, doubleQuoteEscapeSequence} = require('../attributes/matcher');
3 |
4 | const INDEXING_PAGE_SIZE = 1000;
5 |
6 | class SearchMatcherFTS5 extends Matcher {
7 | constructor(index, searchQuery) {
8 | super(null, null, null);
9 |
10 | this.index = index;
11 | this.searchQuery = (
12 | searchQuery.trim()
13 | .replace(/^['"]/, "")
14 | .replace(/['"]$/, "")
15 | .replace(/'/g, singleQuoteEscapeSequence)
16 | .replace(/"/g, doubleQuoteEscapeSequence)
17 | )
18 | }
19 |
20 | attribute() {
21 | return null;
22 | }
23 |
24 | value() {
25 | return null
26 | }
27 |
28 | // The only way to truly check if a model matches this matcher is to run the query
29 | // again and check if the model is in the results. This is too expensive, so we
30 | // will always return true so models aren't excluded from the
31 | // SearchQuerySubscription result set
32 | evaluate() {
33 | return true;
34 | }
35 |
36 | joinSQL(klass) {
37 | const joinTableRef = this.joinTableRef()
38 | return `INNER JOIN \`${this.index.tableName()}\` AS \`${joinTableRef}\` ON \`${joinTableRef}\`.\`content_id\` = \`${klass.name}\`.\`id\``;
39 | }
40 |
41 | whereSQL() {
42 | return `\`${this.index.tableName()}\` MATCH '"${this.searchQuery}"*'`;
43 | }
44 | }
45 |
46 |
47 | export default class SearchIndexFTS5 {
48 | constructor({version, getDataForModel}) {
49 | this.config = {version, getDataForModel};
50 | this.unsubscribers = []
51 | }
52 |
53 | match(query) {
54 | return new SearchMatcherFTS5(this, query);
55 | }
56 |
57 | activate(database) {
58 | this.unsubscribers.push(
59 | database.listen(this._onDataChanged)
60 | );
61 |
62 | database._query(
63 | `SELECT COUNT(content_id) as count FROM \`${this.tableName()}\` LIMIT 1`
64 | ).then((result) => {
65 | if (result[0].count === 0) {
66 | this._populateIndex(database);
67 | }
68 | });
69 | }
70 |
71 | deactivate() {
72 | this.unsubscribers.forEach(unsub => unsub())
73 | }
74 |
75 | tableCreateQuery() {
76 | return (
77 | `CREATE VIRTUAL TABLE IF NOT EXISTS \`${this.tableName()}\`
78 | USING fts5(
79 | tokenize='porter unicode61',
80 | content_id UNINDEXED,
81 | content
82 | )`
83 | );
84 | }
85 |
86 | tableName() {
87 | return `${this.klass.name}_${this.name}_${this.config.version}`;
88 | }
89 |
90 | _populateIndex(database, offset = 0) {
91 | return database.findAll(this.klass)
92 | .limit(INDEXING_PAGE_SIZE)
93 | .offset(offset)
94 | .then((models) => {
95 | if (models.length === 0) {
96 | return;
97 | }
98 |
99 | const next = () => {
100 | const model = models.pop();
101 | if (model) {
102 | this._indexModel(database, model)
103 | setTimeout(next, 5);
104 | } else {
105 | this._populateIndex(database, offset + models.length);
106 | }
107 | }
108 | next();
109 | });
110 | }
111 |
112 | _onDataChanged = (change) => {
113 | if (change.objectClass !== this.klass.name) {
114 | return;
115 | }
116 |
117 | change.objects.forEach((model) => {
118 | if (change.type === 'persist') {
119 | this._indexModel(change._database, model)
120 | } else {
121 | this._unindexModel(change._database, model)
122 | }
123 | });
124 | }
125 |
126 | // Search Index Operations
127 |
128 | /**
129 | @protected
130 | */
131 | _searchIndexSize(database, klass) {
132 | const sql = `SELECT COUNT(content_id) as count FROM \`${this.tableName()}\``;
133 | return database._query(sql).then((result) => result[0].count);
134 | }
135 |
136 | /**
137 | @protected
138 | */
139 | _isModelIndexed(database, model) {
140 | const table = this.tableName();
141 |
142 | return database._query(
143 | `SELECT rowid FROM \`${table}\` WHERE \`${table}\`.\`content_id\` = ? LIMIT 1`,
144 | [model.id],
145 | ).then((results) =>
146 | Promise.resolve(results.length > 0)
147 | )
148 | }
149 |
150 | /**
151 | @protected
152 | */
153 | _indexModel(database, model) {
154 | const table = this.tableName();
155 |
156 | return this._isModelIndexed(database, model).then((isIndexed) => {
157 | if (isIndexed) {
158 | return database._query(
159 | `UPDATE \`${table}\` SET content = ? WHERE \`${table}\`.\`content_id\` = ?`,
160 | [this.config.getDataForModel(model), model.id]
161 | );
162 | }
163 | return database._query(
164 | `INSERT INTO \`${table}\` (\`content_id\`, \`content\`) VALUES (?, ?)`,
165 | [model.id, this.config.getDataForModel(model)]
166 | );
167 | });
168 | }
169 |
170 | /**
171 | @protected
172 | */
173 | _unindexModel(database, model) {
174 | return database._query(
175 | `DELETE FROM \`${this.tableName()}\` WHERE \`${this.tableName()}\`.\`content_id\` = ?`,
176 | [model.id]
177 | );
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/docs/manual/changelog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Changelog
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
31 |
32 |
68 |
69 |
70 |
71 |
72 |
Manual
73 |
»
74 |
Changelog
75 |
76 |
Changelog
77 |
78 | 0.1.0 - Oct 17, 2016
79 | Core components extracted from Nylas N1, converted from CoffeeScript to ES2016
80 | Specs moved from Jasmine 1.3 to Jasmine 2.5, refactored to run without
81 | N1 classes and fixtures.
82 | New "Notes" example application demonstrates use in a minimal Electron app
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/docs/coverage.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage": "36.77%",
3 | "expectCount": 223,
4 | "actualCount": 82,
5 | "files": {
6 | "lib/attributes/attribute.js": {
7 | "expectCount": 12,
8 | "actualCount": 6,
9 | "undocumentLines": [
10 | 11,
11 | 78,
12 | 13,
13 | 12,
14 | 14,
15 | 74
16 | ]
17 | },
18 | "lib/attributes/attribute-boolean.js": {
19 | "expectCount": 4,
20 | "actualCount": 1,
21 | "undocumentLines": [
22 | 16,
23 | 13,
24 | 10
25 | ]
26 | },
27 | "lib/attributes/attribute-collection.js": {
28 | "expectCount": 9,
29 | "actualCount": 2,
30 | "undocumentLines": [
31 | 35,
32 | 33,
33 | 103,
34 | 63,
35 | 36,
36 | 37,
37 | 40
38 | ]
39 | },
40 | "lib/attributes/attribute-datetime.js": {
41 | "expectCount": 8,
42 | "actualCount": 5,
43 | "undocumentLines": [
44 | 22,
45 | 18,
46 | 8
47 | ]
48 | },
49 | "lib/attributes/attribute-joined-data.js": {
50 | "expectCount": 7,
51 | "actualCount": 1,
52 | "undocumentLines": [
53 | 36,
54 | 45,
55 | 55,
56 | 38,
57 | 49,
58 | 41
59 | ]
60 | },
61 | "lib/attributes/attribute-number.js": {
62 | "expectCount": 8,
63 | "actualCount": 1,
64 | "undocumentLines": [
65 | 16,
66 | 12,
67 | 21,
68 | 33,
69 | 27,
70 | 39,
71 | 8
72 | ]
73 | },
74 | "lib/attributes/attribute-object.js": {
75 | "expectCount": 5,
76 | "actualCount": 1,
77 | "undocumentLines": [
78 | 9,
79 | 7,
80 | 16,
81 | 12
82 | ]
83 | },
84 | "lib/attributes/attribute-string.js": {
85 | "expectCount": 6,
86 | "actualCount": 1,
87 | "undocumentLines": [
88 | 24,
89 | 15,
90 | 28,
91 | 20,
92 | 11
93 | ]
94 | },
95 | "lib/browser/coordinator.js": {
96 | "expectCount": 6,
97 | "actualCount": 1,
98 | "undocumentLines": [
99 | 15,
100 | 69,
101 | 79,
102 | 48,
103 | 41
104 | ]
105 | },
106 | "lib/database-change-record.js": {
107 | "expectCount": 5,
108 | "actualCount": 1,
109 | "undocumentLines": [
110 | 40,
111 | 36,
112 | 12,
113 | 61
114 | ]
115 | },
116 | "lib/database-transaction.js": {
117 | "expectCount": 14,
118 | "actualCount": 5,
119 | "undocumentLines": [
120 | 22,
121 | 35,
122 | 23,
123 | 38,
124 | 33,
125 | 32,
126 | 36,
127 | 34,
128 | 59
129 | ]
130 | },
131 | "lib/json-blob.js": {
132 | "expectCount": 4,
133 | "actualCount": 0,
134 | "undocumentLines": [
135 | 11,
136 | 31,
137 | 30,
138 | 26
139 | ]
140 | },
141 | "lib/attributes/matcher.js": {
142 | "expectCount": 12,
143 | "actualCount": 1,
144 | "undocumentLines": [
145 | 45,
146 | 53,
147 | 46,
148 | 44,
149 | 61,
150 | 111,
151 | 107,
152 | 49,
153 | 47,
154 | 57,
155 | 124
156 | ]
157 | },
158 | "lib/model.js": {
159 | "expectCount": 10,
160 | "actualCount": 1,
161 | "undocumentLines": [
162 | 43,
163 | 37,
164 | 30,
165 | 54,
166 | 34,
167 | 103,
168 | 26,
169 | 76,
170 | 93
171 | ]
172 | },
173 | "lib/query.js": {
174 | "expectCount": 29,
175 | "actualCount": 15,
176 | "undocumentLines": [
177 | 77,
178 | 438,
179 | 300,
180 | 244,
181 | 270,
182 | 505,
183 | 483,
184 | 487,
185 | 518,
186 | 467,
187 | 514,
188 | 510,
189 | 127,
190 | 121
191 | ]
192 | },
193 | "lib/mutable-query-result-set.js": {
194 | "expectCount": 8,
195 | "actualCount": 0,
196 | "undocumentLines": [
197 | 5,
198 | 45,
199 | 40,
200 | 19,
201 | 7,
202 | 99,
203 | 106,
204 | 77
205 | ]
206 | },
207 | "lib/mutable-query-subscription.js": {
208 | "expectCount": 2,
209 | "actualCount": 0,
210 | "undocumentLines": [
211 | 3,
212 | 5
213 | ]
214 | },
215 | "lib/query-range.js": {
216 | "expectCount": 14,
217 | "actualCount": 1,
218 | "undocumentLines": [
219 | 71,
220 | 52,
221 | 48,
222 | 7,
223 | 86,
224 | 80,
225 | 76,
226 | 53,
227 | 54,
228 | 11,
229 | 25,
230 | 44,
231 | 93
232 | ]
233 | },
234 | "lib/query-result-set.js": {
235 | "expectCount": 16,
236 | "actualCount": 13,
237 | "undocumentLines": [
238 | 42,
239 | 33,
240 | 23
241 | ]
242 | },
243 | "lib/query-subscription.js": {
244 | "expectCount": 5,
245 | "actualCount": 0,
246 | "undocumentLines": [
247 | 4,
248 | 5,
249 | 68,
250 | 58,
251 | 171
252 | ]
253 | },
254 | "lib/rx-database.js": {
255 | "expectCount": 28,
256 | "actualCount": 25,
257 | "undocumentLines": [
258 | 49,
259 | 468,
260 | 61
261 | ]
262 | },
263 | "lib/attributes/sort-order.js": {
264 | "expectCount": 6,
265 | "actualCount": 1,
266 | "undocumentLines": [
267 | 18,
268 | 26,
269 | 17,
270 | 19,
271 | 22
272 | ]
273 | },
274 | "lib/utils.js": {
275 | "expectCount": 4,
276 | "actualCount": 0,
277 | "undocumentLines": [
278 | 11,
279 | 16,
280 | 1,
281 | 21
282 | ]
283 | },
284 | "lib/console-utils.js": {
285 | "expectCount": 1,
286 | "actualCount": 0,
287 | "undocumentLines": [
288 | 2
289 | ]
290 | }
291 | }
292 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RxDB
2 |
3 | 
4 |
5 | RxDB is a high-performance, observable object store built on top of SQLite.
6 | RxDB draws inspiration from CoreData and Relay and is intended for
7 | database-driven Electron applications. It was originally built for
8 | the [Nylas N1](https://github.com/nylas/N1) mail client.
9 |
10 | View the [API Reference](https://bengotow.github.io/electron-RxDB) on GitHub Pages.
11 |
12 | ## An *Observable* Object Store
13 |
14 | - RxDB queries are [Rx.JS Observables](https://github.com/Reactive-Extensions/RxJS).
15 | Simply create queries and declaratively
16 | bind them to your application's views. Queries internally subscribe to the
17 | database, watch for changes, and vend new versions of their result sets as
18 | necessary. They've been heavily optimized for performance, and often update
19 | without making SQL queries.
20 |
21 | - RxDB databases are event emitters. Want to keep things in sync with your data?
22 | Add listeners to refresh application state as objects are saved and removed.
23 |
24 | Example: Nylas N1 uses a Flux architecture. Many of the Flux Stores in the
25 | application vend state derived from an RxDB database containing the user's
26 | mail data. When an object is written to the database, it emits and event,
27 | and stores (like the UnreadCountStore) can evaluate whether to update
28 | downstream application state.
29 |
30 |
31 | ## Basic Usage
32 |
33 | ### Defining a Model:
34 |
35 | ```js
36 | export default class Note extends Model {
37 | static attributes = Object.assign(Model.attributes, {
38 | name: Attributes.String({
39 | modelKey: 'name',
40 | jsonKey: 'name',
41 | queryable: true,
42 | }),
43 | content: Attributes.String({
44 | modelKey: 'content',
45 | jsonKey: 'content',
46 | }),
47 | createdAt: Attributes.DateTime({
48 | modelKey: 'createdAt',
49 | jsonKey: 'createdAt',
50 | queryable: true,
51 | }),
52 | });
53 | }
54 | ```
55 |
56 | ### Saving a Model:
57 |
58 | ```js
59 | const note = new Note({
60 | name: 'Untitled',
61 | content: 'Write your note here!',
62 | createdAt: new Date(),
63 | });
64 | database.inTransaction((t) => {
65 | return t.persistModel(note);
66 | });
67 | ```
68 |
69 | ### Querying for Models:
70 |
71 | ```js
72 | database
73 | .findAll(Note)
74 | .where({name: 'Untitled'})
75 | .order(Note.attributes.createdAt.descending())
76 | .then((notes) => {
77 | // got some notes!
78 | })
79 | ```
80 |
81 | ### Observing a query:
82 |
83 | ```js
84 | componentDidMount() {
85 | const query = database
86 | .findAll(Note)
87 | .where({name: 'Untitled'})
88 | .order(Note.attributes.createdAt.descending())
89 |
90 | this._observable = query.observe().subscribe((items) => {
91 | this.setState({items});
92 | });
93 | }
94 | ```
95 |
96 | ## Features
97 |
98 | - Models:
99 | + Definition via ES2016 classes extending `RxDB.Model`
100 | + Out of the box support for JSON serialization
101 | + Easy attribute definition and validation
102 | + Automatic schema generation based on `queryable` fields
103 | + Support for splitting object data across tables, keeping SQLite row size small
104 |
105 | - Queries:
106 | + Results via a Promise API. (`query.then((results) => ...)``)
107 | + Live results via an Rx.JS Observable API. (`query.observable().subscribe((results) => ...)`)
108 | + Clean query syntax inspired by ActiveRecord and NSPredicate
109 | + Support for basic relationships and retrieving of joined objects
110 | + Full-text search powered by SQLite's FTS5
111 |
112 | - Database:
113 | + ChangeRecord objects emitted for every modification of data
114 | + Changes bridged across window processes for multi-window apps
115 | + Support for opening multiple databases simultaneously
116 |
117 | - High test coverage!
118 |
119 | ## FAQ
120 | ### How does this fit in to Flux / Redux / etc?
121 |
122 | RxDB is not intended to be a replacement for [Redux](https://github.com/reactjs/redux)
123 | or other application state frameworks, and works great alongside them!
124 |
125 | Redux is ideal for storing small bits of state, like the user's current selection.
126 | In a typical RxDB application, this application state determines the views that
127 | are displayed and the queries that are declaratively bound to those views. Individual
128 | components build queries and display the resulting data.
129 |
130 | ### Wait, I can't make `UPDATE` queries?
131 |
132 | RxDB exposes an ActiveRecord-style query syntax, but only for fetching models.
133 | RxDB's powerful observable queries, modification hooks, and other features
134 | depend on application code being able to see every change to every object.
135 |
136 | Queries like `UPDATE Note SET read = 1 WHERE ...` allow you to make
137 | changes with unknown effects, and are explicitly not allowed.
138 | (Every live query of a Note would need to be re-run following that change!)
139 | Instead of expanding support for arbitrary queries, RxDB focuses on making
140 | reading and saving objects *blazing fast*, so doing a query, modifying a few
141 | hundred matches, and saving them back is perfectly fine.
142 |
143 | ## Examples & API Reference
144 |
145 |
146 |
147 | The example "Notes" app may be the best place to get started, but a full
148 | [API Reference](https://bengotow.github.io/electron-RxDB) is available
149 | on GitHub Pages.
150 |
151 | ## Contributing
152 |
153 | #### Running the Notes Example
154 |
155 | ```bash
156 | npm install
157 | cd ./example
158 | npm install
159 | npm start
160 | ```
161 |
162 | #### Running the Tests
163 |
164 | RxDB's tests are written in [Jasmine 2](jasmine.github.io/2.5/introduction.html)
165 | and run in a tiny Electron application for consistency with the target environment.
166 | To run the tests, use `npm test`:
167 |
168 | ```bash
169 | npm install
170 | npm test
171 | ```
172 |
173 | You can skip certain tests (temporarily) with `xit` and `xdescribe`,
174 | or focus on only certain tests with `fit` and `fdescribe`.
175 |
176 | #### Running the Linter
177 |
178 | ```
179 | npm install
180 | npm run lint
181 | ```
182 |
--------------------------------------------------------------------------------
/spec/mutable-query-result-set-spec.js:
--------------------------------------------------------------------------------
1 | /* eslint quote-props: 0 */
2 | import MutableQueryResultSet from '../src/mutable-query-result-set';
3 | import QueryRange from '../src/query-range';
4 |
5 | describe("MutableQueryResultSet", function MutableQueryResultSetSpecs() {
6 | describe("clipToRange", () => {
7 | it("should do nothing if the clipping range is infinite", () => {
8 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
9 | const beforeRange = set.range();
10 | set.clipToRange(QueryRange.infinite());
11 | const afterRange = set.range();
12 |
13 | expect(beforeRange.isEqual(afterRange)).toBe(true);
14 | });
15 |
16 | it("should correctly trim the result set 5-10 to the clipping range 2-9", () => {
17 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
18 | expect(set.range().isEqual(new QueryRange({offset: 5, limit: 5}))).toBe(true);
19 | set.clipToRange(new QueryRange({offset: 2, limit: 7}));
20 | expect(set.range().isEqual(new QueryRange({offset: 5, limit: 4}))).toBe(true);
21 | expect(set.ids()).toEqual(['A', 'B', 'C', 'D']);
22 | });
23 |
24 | it("should correctly trim the result set 5-10 to the clipping range 5-10", () => {
25 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
26 | set.clipToRange(new QueryRange({start: 5, end: 10}));
27 | expect(set.range().isEqual(new QueryRange({start: 5, end: 10}))).toBe(true);
28 | expect(set.ids()).toEqual(['A', 'B', 'C', 'D', 'E']);
29 | });
30 |
31 | it("should correctly trim the result set 5-10 to the clipping range 6", () => {
32 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
33 | set.clipToRange(new QueryRange({offset: 6, limit: 1}));
34 | expect(set.range().isEqual(new QueryRange({offset: 6, limit: 1}))).toBe(true);
35 | expect(set.ids()).toEqual(['B']);
36 | });
37 |
38 | it("should correctly trim the result set 5-10 to the clipping range 100-200", () => {
39 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
40 | set.clipToRange(new QueryRange({start: 100, end: 200}));
41 | expect(set.range().isEqual(new QueryRange({start: 100, end: 100}))).toBe(true);
42 | expect(set.ids()).toEqual([]);
43 | });
44 |
45 | it("should correctly trim the result set 5-10 to the clipping range 0-2", () => {
46 | const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
47 | set.clipToRange(new QueryRange({offset: 0, limit: 2}));
48 | expect(set.range().isEqual(new QueryRange({offset: 5, limit: 0}))).toBe(true);
49 | expect(set.ids()).toEqual([]);
50 | });
51 |
52 | it("should trim the models cache to remove models no longer needed", () => {
53 | const set = new MutableQueryResultSet({
54 | _ids: ['A', 'B', 'C', 'D', 'E'],
55 | _offset: 5,
56 | _modelsHash: {
57 | 'A': {id: 'A'},
58 | 'B': {id: 'B'},
59 | 'C': {id: 'C'},
60 | 'D': {id: 'D'},
61 | 'E': {id: 'E'},
62 | }});
63 |
64 | set.clipToRange(new QueryRange({start: 5, end: 8}));
65 | expect(set._modelsHash).toEqual({
66 | 'A': {id: 'A'},
67 | 'B': {id: 'B'},
68 | 'C': {id: 'C'},
69 | });
70 | });
71 | });
72 |
73 | describe("addIdsInRange", () => {
74 | describe("when the set is currently empty", () =>
75 | it("should set the result set to the provided one", () => {
76 | this.set = new MutableQueryResultSet();
77 | this.set.addIdsInRange(['B', 'C', 'D'], new QueryRange({start: 1, end: 4}));
78 | expect(this.set.ids()).toEqual(['B', 'C', 'D']);
79 | expect(this.set.range().isEqual(new QueryRange({start: 1, end: 4}))).toBe(true);
80 | })
81 |
82 | );
83 |
84 | describe("when the set has existing values", () => {
85 | beforeEach(() => {
86 | this.set = new MutableQueryResultSet({
87 | _ids: ['A', 'B', 'C', 'D', 'E'],
88 | _offset: 5,
89 | _modelsHash: {'A': {id: 'A'}, 'B': {id: 'B'}, 'C': {id: 'C'}, 'D': {id: 'D'}, 'E': {id: 'E'}},
90 | });
91 | });
92 |
93 | it("should throw an exception if the range provided doesn't intersect (trailing)", () => {
94 | expect(() => {
95 | this.set.addIdsInRange(['G', 'H', 'I'], new QueryRange({offset: 11, limit: 3}));
96 | }).toThrow();
97 |
98 | expect(() => {
99 | this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 3}));
100 | }).not.toThrow();
101 | });
102 |
103 | it("should throw an exception if the range provided doesn't intersect (leading)", () => {
104 | expect(() => {
105 | this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 1, limit: 3}));
106 | }).toThrow();
107 |
108 | expect(() => {
109 | this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 2, limit: 3}));
110 | }).not.toThrow();
111 | });
112 |
113 | it("should work if the IDs array is shorter than the result range they represent (addition)", () => {
114 | this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 5}));
115 | expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);
116 | });
117 |
118 | it("should work if the IDs array is shorter than the result range they represent (replacement)", () => {
119 | this.set.addIdsInRange(['A', 'B', 'C'], new QueryRange({offset: 5, limit: 5}));
120 | expect(this.set.ids()).toEqual(['A', 'B', 'C']);
121 | });
122 |
123 | it("should correctly add ids (trailing) and update the offset", () => {
124 | this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 3}));
125 | expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);
126 | expect(this.set.range().offset).toEqual(5);
127 | });
128 |
129 | it("should correctly add ids (leading) and update the offset", () => {
130 | this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 2, limit: 3}));
131 | expect(this.set.ids()).toEqual(['0', '1', '2', 'A', 'B', 'C', 'D', 'E']);
132 | expect(this.set.range().offset).toEqual(2);
133 | });
134 |
135 | it("should correctly add ids (middle) and update the offset", () => {
136 | this.set.addIdsInRange(['B-new', 'C-new', 'D-new'], new QueryRange({offset: 6, limit: 3}));
137 | expect(this.set.ids()).toEqual(['A', 'B-new', 'C-new', 'D-new', 'E']);
138 | expect(this.set.range().offset).toEqual(5);
139 | });
140 |
141 | it("should correctly add ids (middle+trailing) and update the offset", () => {
142 | this.set.addIdsInRange(['D-new', 'E-new', 'F-new'], new QueryRange({offset: 8, limit: 3}));
143 | expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D-new', 'E-new', 'F-new']);
144 | expect(this.set.range().offset).toEqual(5);
145 | });
146 | });
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/src/attributes/matcher.js:
--------------------------------------------------------------------------------
1 | import {tableNameForJoin} from '../utils';
2 |
3 | // https://www.sqlite.org/faq.html#q14
4 | // That's right. Two single quotes in a row…
5 | export const singleQuoteEscapeSequence = "''";
6 |
7 | // https://www.sqlite.org/fts5.html#section_3
8 | export const doubleQuoteEscapeSequence = '""';
9 |
10 |
11 | /**
12 | The Matcher class encapsulates a particular comparison clause on an {@link Attribute}.
13 | Matchers can evaluate whether or not an object matches them, and also compose
14 | SQL clauses for the {@link RxDatabase}. Each matcher has a reference to a model
15 | attribute, a comparator and a value. This class is heavily inspired by
16 | NSPredicate on Mac OS X / CoreData.
17 |
18 | ```js
19 |
20 | // Retrieving Matchers
21 |
22 | const isUnread = Thread.attributes.unread.equal(true);
23 |
24 | const hasLabel = Thread.attributes.categories.contains('label-id-123');
25 |
26 | // Using Matchers in Database Queries
27 |
28 | const db.findAll(Thread).where(isUnread)...
29 |
30 | // Using Matchers to test Models
31 |
32 | const threadA = new Thread({unread: true})
33 | const threadB = new Thread({unread: false})
34 |
35 | isUnread.evaluate(threadA)
36 | // => true
37 |
38 | isUnread.evaluate(threadB)
39 | // => false
40 |
41 | ```
42 | */
43 | class Matcher {
44 | constructor(attr, comparator, val) {
45 | this.attr = attr;
46 | this.comparator = comparator;
47 | this.val = val;
48 |
49 | this.muid = Matcher.muid;
50 | Matcher.muid = (Matcher.muid + 1) % 50;
51 | }
52 |
53 | attribute() {
54 | return this.attr;
55 | }
56 |
57 | value() {
58 | return this.val;
59 | }
60 |
61 | evaluate(model) {
62 | let modelValue = model[this.attr.modelKey];
63 | if (modelValue instanceof Function) {
64 | modelValue = modelValue()
65 | }
66 | const matcherValue = this.val;
67 |
68 | // Given an array of strings or models, and a string or model search value,
69 | // will find if a match exists.
70 | const modelArrayContainsValue = (array, searchItem) => {
71 | const asId = (v) => ((v && v.id) ? v.id : v);
72 | const search = asId(searchItem)
73 | for (const item of array) {
74 | if (asId(item) === search) {
75 | return true;
76 | }
77 | }
78 | return false;
79 | }
80 |
81 | switch (this.comparator) {
82 | case '=':
83 | return modelValue === matcherValue
84 | case '<':
85 | return modelValue < matcherValue
86 | case '>':
87 | return modelValue > matcherValue
88 | case '<=':
89 | return modelValue <= matcherValue
90 | case '>=':
91 | return modelValue >= matcherValue
92 | case 'in':
93 | return matcherValue.includes(modelValue)
94 | case 'contains':
95 | return modelArrayContainsValue(modelValue, matcherValue)
96 | case 'containsAny':
97 | return !!matcherValue.find((submatcherValue) => modelArrayContainsValue(modelValue, submatcherValue))
98 | case 'startsWith':
99 | return modelValue.startsWith(matcherValue)
100 | case 'like':
101 | return modelValue.search(new RegExp(`.*${matcherValue}.*`, "gi")) >= 0
102 | default:
103 | throw new Error(`Matcher.evaulate() not sure how to evaluate ${this.attr.modelKey} with comparator ${this.comparator}`)
104 | }
105 | }
106 |
107 | joinTableRef() {
108 | return `M${this.muid}`;
109 | }
110 |
111 | joinSQL(klass) {
112 | switch (this.comparator) {
113 | case 'contains':
114 | case 'containsAny': {
115 | const joinTable = tableNameForJoin(klass, this.attr.itemClass);
116 | const joinTableRef = this.joinTableRef();
117 | return `INNER JOIN \`${joinTable}\` AS \`${joinTableRef}\` ON \`${joinTableRef}\`.\`id\` = \`${klass.name}\`.\`id\``;
118 | }
119 | default:
120 | return false;
121 | }
122 | }
123 |
124 | whereSQL(klass) {
125 | const val = (this.comparator === "like") ? `%${this.val}%` : this.val;
126 | let escaped = null;
127 |
128 | if (typeof val === 'string') {
129 | escaped = `'${val.replace(/'/g, singleQuoteEscapeSequence)}'`;
130 | } else if (val === true) {
131 | escaped = 1
132 | } else if (val === false) {
133 | escaped = 0
134 | } else if (val instanceof Date) {
135 | escaped = val.getTime() / 1000
136 | } else if (val instanceof Array) {
137 | const escapedVals = []
138 | for (const v of val) {
139 | if (typeof v !== 'string') {
140 | throw new Error(`${this.attr.jsonKey} value ${v} must be a string.`);
141 | }
142 | escapedVals.push(`'${v.replace(/'/g, singleQuoteEscapeSequence)}'`);
143 | }
144 | escaped = `(${escapedVals.join(',')})`;
145 | } else {
146 | escaped = val;
147 | }
148 |
149 | switch (this.comparator) {
150 | case 'startsWith':
151 | return " RAISE `TODO`; ";
152 | case 'contains':
153 | return `\`${this.joinTableRef()}\`.\`value\` = ${escaped}`;
154 | case 'containsAny':
155 | return `\`${this.joinTableRef()}\`.\`value\` IN ${escaped}`;
156 | default:
157 | return `\`${klass.name}\`.\`${this.attr.jsonKey}\` ${this.comparator} ${escaped}`;
158 | }
159 | }
160 | }
161 |
162 | Matcher.muid = 0
163 |
164 | /**
165 | This subclass is publicly exposed as Matcher.Or.
166 | @private
167 | */
168 | class OrCompositeMatcher extends Matcher {
169 | constructor(children) {
170 | super();
171 | this.children = children;
172 | }
173 |
174 | attribute() {
175 | return null;
176 | }
177 |
178 | value() {
179 | return null;
180 | }
181 |
182 | evaluate(model) {
183 | return this.children.some((matcher) => matcher.evaluate(model));
184 | }
185 |
186 | joinSQL(klass) {
187 | const joins = []
188 | for (const matcher of this.children) {
189 | const join = matcher.joinSQL(klass);
190 | if (join) {
191 | joins.push(join);
192 | }
193 | }
194 | return (joins.length) ? joins.join(" ") : false;
195 | }
196 |
197 | whereSQL(klass) {
198 | const wheres = this.children.map((matcher) => matcher.whereSQL(klass));
199 | return `(${wheres.join(" OR ")})`;
200 | }
201 | }
202 |
203 | /**
204 | This subclass is publicly exposed as Matcher.And.
205 | @private
206 | */
207 | class AndCompositeMatcher extends Matcher {
208 | constructor(children) {
209 | super();
210 | this.children = children;
211 | }
212 |
213 | attribute() {
214 | return null;
215 | }
216 |
217 | value() {
218 | return null;
219 | }
220 |
221 | evaluate(model) {
222 | return this.children.every((m) => m.evaluate(model));
223 | }
224 |
225 | joinSQL(klass) {
226 | const joins = []
227 | for (const matcher of this.children) {
228 | const join = matcher.joinSQL(klass);
229 | if (join) {
230 | joins.push(join);
231 | }
232 | }
233 | return joins;
234 | }
235 |
236 | whereSQL(klass) {
237 | const wheres = this.children.map((m) => m.whereSQL(klass));
238 | return `(${wheres.join(" AND ")})`;
239 | }
240 | }
241 |
242 | /**
243 | This subclass is publicly exposed as Matcher.Not.
244 | @private
245 | */
246 | class NotCompositeMatcher extends AndCompositeMatcher {
247 | whereSQL(klass) {
248 | return `NOT (${super.whereSQL(klass)})`;
249 | }
250 | }
251 |
252 | Matcher.Or = OrCompositeMatcher
253 | Matcher.And = AndCompositeMatcher
254 | Matcher.Not = NotCompositeMatcher
255 |
256 | export default Matcher;
257 |
--------------------------------------------------------------------------------
/example/initial-notes/4. Nintendo 64.html:
--------------------------------------------------------------------------------
1 | The Nintendo 64 (Japanese : ニンテンドウ64 , Hepburn : Nintendō Rokujūyon ? ) , stylized as NINTENDO64 and often referred to as N64 , is Nintendo 's third home video game console for the international market. Named for its 64-bit central processing unit , it was released in June 1996 in Japan , September 1996 in North America , March 1997 in Europe and Australia , September 1997 in France and December 1997 in Brazil . It is the industry's last major home console to use the cartridge as its primary storage format, although current handheld systems (such as the PlayStation Vita and Nintendo 3DS ) also use cartridges. While the Nintendo 64 was succeeded by Nintendo's MiniDVD -based GameCube in November 2001, the consoles remained available until the system was retired in late 2003.
2 | Code named Project Reality , the console's design was mostly finalized by mid-1995, though Nintendo 64's launch was delayed until 1996.[6] As part of the fifth generation of gaming , the system competed primarily with the PlayStation and the Sega Saturn . The Nintendo 64 was launched with three games : Super Mario 64 and Pilotwings 64 , released worldwide; and Saikyō Habu Shōgi , released only in Japan. The Nintendo 64's suggested retail price at its United States launch was US$199.99 and it was later marketed with the slogan "Get N, or get Out!". With 32.93 million units worldwide, the console was ultimately released in a range of different colors and designs, and an assortment of limited-edition controllers were sold or used as contest prizes during the system's lifespan. IGN named it the 9th greatest video game console of all time;[7] and in 1996, Time Magazine named it Machine of the Year.
3 | At the beginning of the 1990s, Nintendo led the video game industry with its Nintendo Entertainment System (NES). Although the NES follow-up console, the Super NES (SNES), was successful, sales took a hit from the Japanese recession . Competition from long-time rival Sega, and relative newcomer Sony, emphasized Nintendo's need to develop a successor for the SNES, or risk losing market dominance to its rivals. Further complicating matters, Nintendo also faced a backlash from third-party developers unhappy with Nintendo's strict licensing policies.[9]
4 | Silicon Graphics, Inc. (SGI), a long-time leader in graphics visualization and supercomputing, was interested in expanding its business by adapting its technology into the higher volume realm of consumer products, starting with the video game market. Based upon its MIPS R4000 family of supercomputing and workstation CPUs, SGI developed a CPU requiring a fraction of the resources—consuming only 0.5 watts of power instead of 1.5 to 2 watts, with an estimated target price of US$40 instead of US$80 –200.[10] The company created a design proposal for a video game system, seeking an already well established partner in that market. James H. Clark , founder of SGI, initially offered the proposal to Tom Kalinske , who was the CEO of Sega of America. The next candidate was Nintendo.
5 | The historical details of these preliminary negotiations were controversial between the two competing suitors.[9] Tom Kalinske said that he and Joe Miller of Sega of America were "quite impressed" with SGI's prototype, inviting their hardware team to travel from Japan to meet with SGI. The engineers from Sega Enterprises claimed that their evaluation of the early prototype had uncovered several unresolved hardware issues and deficiencies. Those were subsequently resolved, but Sega had already decided against SGI's design.[11] Nintendo resisted that summary conclusion, arguing that the reason for SGI's ultimate choice of partner is due to Nintendo having been a more appealing business partner than Sega.[9] While Sega demanded exclusive rights to the chip, Nintendo was willing to license the technology on a non-exclusive basis.[9] Michael Slater, publisher of Microprocessor Report said, "The mere fact of a business relationship there is significant because of Nintendo's phenomenal ability to drive volume. If it works at all, it could bring MIPS to levels of volume [SGI] never dreamed of".[10]
6 |
--------------------------------------------------------------------------------
/docs/file/lib/attributes/attribute-boolean.js.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | lib/attributes/attribute-boolean.js | electron-RxDB API Document
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
31 |
32 |
64 |
65 |
66 | lib/attributes/attribute-boolean.js
67 |
import Attribute from './attribute';
68 |
69 | /**
70 | The value of this attribute is always a boolean. Null values are coerced to false.
71 |
72 | String attributes can be queries using `equal` and `not`. Matching on
73 | `greaterThan` and `lessThan` is not supported.
74 | */
75 | export default class AttributeBoolean extends Attribute {
76 | toJSON(val) {
77 | return val;
78 | }
79 | fromJSON(val) {
80 | return ((val === 'true') || (val === true)) || false;
81 | }
82 | columnSQL() {
83 | return `${this.jsonKey} INTEGER`;
84 | }
85 | }
86 |
87 |
88 |
89 |
90 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/docs/manual/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Manual
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
31 |
32 |
68 |
69 |
70 |
71 |
72 |
90 |
98 |
105 |
106 |
107 |
108 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/docs/file/lib/json-blob.js.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | lib/json-blob.js | electron-RxDB API Document
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
31 |
32 |
64 |
65 |
66 | lib/json-blob.js
67 |
import Model from './model';
68 | import Query from './query';
69 | import Attributes from './attributes';
70 |
71 | class JSONBlobQuery extends Query {
72 | formatResult(objects) {
73 | return objects[0] ? objects[0].json : null;
74 | }
75 | }
76 |
77 | export default class JSONBlob extends Model {
78 | static Query = JSONBlobQuery;
79 |
80 | static attributes = {
81 | id: Attributes.String({
82 | queryable: true,
83 | modelKey: 'id',
84 | }),
85 |
86 | json: Attributes.Object({
87 | modelKey: 'json',
88 | jsonKey: 'json',
89 | }),
90 | };
91 |
92 | get key() {
93 | return this.id;
94 | }
95 |
96 | set key(val) {
97 | this.id = val;
98 | }
99 | }
100 |
101 |
102 |
103 |
104 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/docs/file/lib/utils.js.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | lib/utils.js | electron-RxDB API Document
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
31 |
32 |
64 |
65 |
66 | lib/utils.js
67 |
export function modelFreeze(o) {
68 | Object.freeze(o);
69 | return Object.getOwnPropertyNames(o).forEach((key) => {
70 | const val = o[key];
71 | if (typeof val === 'object' && val !== null && !Object.isFrozen(val)) {
72 | modelFreeze(val);
73 | }
74 | });
75 | }
76 |
77 | export function generateTempId() {
78 | const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
79 | return `local-${s4()}${s4()}-${s4()}`;
80 | }
81 |
82 | export function isTempId(id) {
83 | if (!id || typeof id !== 'string') { return false; }
84 | return id.slice(0, 6) === 'local-';
85 | }
86 |
87 | export function tableNameForJoin(primaryKlass, secondaryKlass) {
88 | return `${primaryKlass.name}${secondaryKlass.name}`;
89 | }
90 |
91 |
92 |
93 |
94 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------