@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
297 lines (262 loc) • 9.42 kB
JavaScript
// @flow
import { Observable, Subject } from '../utils/rx'
import invariant from '../utils/common/invariant'
import {
noop,
fromArrayOrSpread,
// eslint-disable-next-line no-unused-vars
type ArrayOrSpreadFn,
} from '../utils/fp'
import { type ResultCallback, toPromise, mapValue } from '../utils/fp/Result'
import { type Unsubscribe } from '../utils/subscriptions'
import Query from '../Query'
import type Database from '../Database'
import type Model, { RecordId } from '../Model'
import type { Clause } from '../QueryDescription'
import { type TableName, type TableSchema } from '../Schema'
import { type DirtyRaw } from '../RawRecord'
import RecordCache from './RecordCache'
type CollectionChangeType = 'created' | 'updated' | 'destroyed'
export type CollectionChange<Record: Model> = { record: Record, type: CollectionChangeType }
export type CollectionChangeSet<T> = CollectionChange<T>[]
export default class Collection<Record: Model> {
database: Database
/**
* `Model` subclass associated with this Collection
*/
modelClass: Class<Record>
/**
* An `Rx.Subject` that emits a signal on every change (record creation/update/deletion) in
* this Collection.
*
* The emissions contain information about which record was changed and what the change was.
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately. You generally should just use the `Query` API.
*/
changes: Subject<CollectionChangeSet<Record>> = new Subject()
_cache: RecordCache<Record>
constructor(database: Database, ModelClass: Class<Record>): void {
this.database = database
this.modelClass = ModelClass
this._cache = new RecordCache<Record>(
(ModelClass.table: $FlowFixMe),
(raw) => new ModelClass((this: $FlowFixMe), raw),
this,
)
}
/**
* `Database` associated with this Collection.
*/
get db(): Database {
return this.database
}
/**
* Table name associated with this Collection
*/
get table(): TableName<Record> {
// $FlowFixMe
return this.modelClass.table
}
/**
* Table schema associated with this Collection
*/
get schema(): TableSchema {
return this.database.schema.tables[this.table]
}
/**
* Fetches the record with the given ID.
*
* If the record is not found, the Promise will reject.
*/
async find(id: RecordId): Promise<Record> {
return toPromise((callback) => this._fetchRecord(id, callback))
}
/**
* Fetches the given record and then starts observing it.
*
* This is a convenience method that's equivalent to
* `collection.find(id)`, followed by `record.observe()`.
*/
findAndObserve(id: RecordId): Observable<Record> {
return Observable.create((observer) => {
let unsubscribe = null
let unsubscribed = false
this._fetchRecord(id, (result) => {
if (result.value) {
const record = result.value
observer.next(record)
unsubscribe = record.experimentalSubscribe((isDeleted) => {
if (!unsubscribed) {
isDeleted ? observer.complete() : observer.next(record)
}
})
} else {
// $FlowFixMe
observer.error(result.error)
}
})
return () => {
unsubscribed = true
unsubscribe && unsubscribe()
}
})
}
/*:: query: ArrayOrSpreadFn<Clause, Query<Record>> */
/**
* Returns a `Query` with conditions given.
*
* You can pass conditions as multiple arguments or a single array.
*
* See docs for details about the Query API.
*/
// $FlowFixMe
query(...args: Clause[]): Query<Record> {
const clauses = fromArrayOrSpread<Clause>(args, 'Collection.query', 'Clause')
return new Query(this, clauses)
}
/**
* Creates a new record.
* Pass a function to set attributes of the new record.
*
* Note: This method must be called within a Writer {@link Database#write}.
*
* @example
* ```js
* db.get(Tables.tasks).create(task => {
* task.name = 'Task name'
* })
* ```
*/
async create(recordBuilder: (Record) => void = noop): Promise<Record> {
this.database._ensureInWriter(`Collection.create()`)
const record = this.prepareCreate(recordBuilder)
await this.database.batch(record)
return record
}
/**
* Prepares a new record to be created
*
* Use this to batch-execute multiple changes at once.
* @see {Collection#create}
* @see {Database#batch}
*/
prepareCreate(recordBuilder: (Record) => void = noop): Record {
// $FlowFixMe
return this.modelClass._prepareCreate(this, recordBuilder)
}
/**
* Prepares a new record to be created, based on a raw object.
*
* Don't use this unless you know how RawRecords work in WatermelonDB. See docs for more details.
*
* This is useful as a performance optimization, when adding online-only features to an otherwise
* offline-first app, or if you're implementing your own sync mechanism.
*/
prepareCreateFromDirtyRaw(dirtyRaw: DirtyRaw): Record {
// $FlowFixMe
return this.modelClass._prepareCreateFromDirtyRaw(this, dirtyRaw)
}
/**
* Returns a disposable record, based on a raw object.
*
* A disposable record is a read-only record that **does not** exist in the actual database. It's
* not cached and cannot be saved in the database, updated, deleted, queried, or found by ID. It
* only exists for as long as you keep a reference to it.
*
* Don't use this unless you know how RawRecords work in WatermelonDB. See docs for more details.
*
* This is useful for adding online-only features to an otherwise offline-first app, or for
* temporary objects that are not meant to be persisted (as you can reuse existing Model helpers
* and compatible UI components to display a disposable record).
*/
disposableFromDirtyRaw(dirtyRaw: DirtyRaw): Record {
// $FlowFixMe
return this.modelClass._disposableFromDirtyRaw(this, dirtyRaw)
}
// *** Implementation details ***
// See: Query.fetch
_fetchQuery(query: Query<Record>, callback: ResultCallback<Record[]>): void {
this.database.adapter.underlyingAdapter.query(query.serialize(), (result) =>
callback(mapValue((rawRecords) => this._cache.recordsFromQueryResult(rawRecords), result)),
)
}
_fetchIds(query: Query<Record>, callback: ResultCallback<RecordId[]>): void {
this.database.adapter.underlyingAdapter.queryIds(query.serialize(), callback)
}
_fetchCount(query: Query<Record>, callback: ResultCallback<number>): void {
this.database.adapter.underlyingAdapter.count(query.serialize(), callback)
}
_unsafeFetchRaw(query: Query<Record>, callback: ResultCallback<any[]>): void {
this.database.adapter.underlyingAdapter.unsafeQueryRaw(query.serialize(), callback)
}
// Fetches exactly one record (See: Collection.find)
_fetchRecord(id: RecordId, callback: ResultCallback<Record>): void {
if (typeof id !== 'string') {
callback({ error: new Error(`Invalid record ID ${this.table}#${id}`) })
return
}
const cachedRecord = this._cache.get(id)
if (cachedRecord) {
callback({ value: cachedRecord })
return
}
this.database.adapter.underlyingAdapter.find(this.table, id, (result) =>
callback(
mapValue((rawRecord) => {
invariant(rawRecord, `Record ${this.table}#${id} not found`)
return this._cache.recordFromQueryResult(rawRecord)
}, result),
),
)
}
_applyChangesToCache(operations: CollectionChangeSet<Record>): void {
operations.forEach(({ record, type }) => {
if (type === 'created') {
record._preparedState = null
this._cache.add(record)
} else if (type === 'destroyed') {
this._cache.delete(record)
}
})
}
_notify(operations: CollectionChangeSet<Record>): void {
const collectionChangeNotifySubscribers = ([subscriber]: [
(CollectionChangeSet<Record>) => void,
any,
]): void => {
subscriber(operations)
}
this._subscribers.forEach(collectionChangeNotifySubscribers)
this.changes.next(operations)
const collectionChangeNotifyModels = ({ record, type }: CollectionChange<Record>): void => {
if (type === 'updated') {
record._notifyChanged()
} else if (type === 'destroyed') {
record._notifyDestroyed()
}
}
operations.forEach(collectionChangeNotifyModels)
}
_subscribers: [(CollectionChangeSet<Record>) => void, any][] = []
/**
* Notifies `subscriber` on every change (record creation/update/deletion) in this Collection.
*
* Notifications contain information about which record was changed and what the change was.
* (Currently, subscribers are called before `changes` emissions, but this behavior might change)
*
* Warning: You can easily introduce performance bugs in your application by using this method
* inappropriately. You generally should just use the `Query` API.
*/
experimentalSubscribe(
subscriber: (CollectionChangeSet<Record>) => void,
debugInfo?: any,
): Unsubscribe {
const entry = [subscriber, debugInfo]
this._subscribers.push(entry)
return () => {
const idx = this._subscribers.indexOf(entry)
idx !== -1 && this._subscribers.splice(idx, 1)
}
}
}