diff --git a/addon/-private/flatten-doc-snapshot.ts b/addon/-private/flatten-doc-snapshot.ts index 475096b2..89a9840a 100644 --- a/addon/-private/flatten-doc-snapshot.ts +++ b/addon/-private/flatten-doc-snapshot.ts @@ -1,6 +1,9 @@ +import { DocumentSnapshot } from 'firebase/firestore'; import firebase from 'firebase/compat/app'; -export default function flattenDocSnapshot(docSnapshot: firebase.firestore.DocumentSnapshot): { +export default function flattenDocSnapshot( + docSnapshot: firebase.firestore.DocumentSnapshot | DocumentSnapshot, +): { id: string, [key: string]: unknown, } { diff --git a/addon/-private/realtime-tracker-modular.ts b/addon/-private/realtime-tracker-modular.ts new file mode 100644 index 00000000..3e25cfc3 --- /dev/null +++ b/addon/-private/realtime-tracker-modular.ts @@ -0,0 +1,201 @@ +/* + eslint + @typescript-eslint/no-empty-function: off, + ember/use-ember-data-rfc-395-imports: off +*/ + +import { next } from '@ember/runloop'; +import DS from 'ember-data'; +import Store from '@ember-data/store'; + +import { CollectionReference, DocumentReference, Query } from 'firebase/firestore'; + +import { onSnapshot } from 'ember-cloud-firestore-adapter/firebase/firestore'; +import flattenDocSnapshotData from 'ember-cloud-firestore-adapter/-private/flatten-doc-snapshot'; + +interface ModelTracker { + [key: string]: { + record: { + [key: string]: { hasOnSnapshotRunAtLeastOnce: boolean }, + }, + meta: { hasOnSnapshotRunAtLeastOnce: boolean, hasTrackedAllRecords: boolean }, + }; +} + +interface QueryTracker { + [key: string]: { + hasOnSnapshotRunAtLeastOnce: boolean, + + unsubscribe(): void, + }; +} + +export default class RealtimeTracker { + private modelTracker: ModelTracker = {}; + + private queryTracker: QueryTracker = {}; + + private store: Store; + + constructor(store: Store) { + this.store = store; + } + + public trackFindRecordChanges(modelName: string, docRef: DocumentReference): void { + const { id } = docRef; + + if (!this.isRecordTracked(modelName, id)) { + this.trackModel(modelName); + + this.modelTracker[modelName].record[id] = { hasOnSnapshotRunAtLeastOnce: false }; + + const unsubscribe = onSnapshot(docRef, (docSnapshot) => { + if (this.modelTracker[modelName].record[id].hasOnSnapshotRunAtLeastOnce) { + if (docSnapshot.exists()) { + const record = this.store.peekRecord(modelName, id); + + if (record && !record.isSaving) { + const flatRecord = flattenDocSnapshotData(docSnapshot); + const normalizedRecord = this.store.normalize(modelName, flatRecord); + + this.store.push(normalizedRecord); + } + } else { + unsubscribe(); + this.unloadRecord(modelName, id); + } + } else { + this.modelTracker[modelName].record[id].hasOnSnapshotRunAtLeastOnce = true; + } + }, (error) => { + const record = this.store.peekRecord(modelName, id); + + if (record) { + // When we lose permission to view the document, we unload it from the store. However, + // any template that has rendered the record will still be intact even if it no longer + // exists in the store. + // + // We set some properties here to give our templates the opportunity to react to this + // scenario. + record.set('isUnloaded', true); + record.set('unloadReason', error); + this.unloadRecord(modelName, id); + } + + delete this.modelTracker[modelName].record[id]; + }); + } + } + + public trackFindAllChanges(modelName: string, collectionRef: CollectionReference): void { + if (!this.modelTracker[modelName]?.meta.hasTrackedAllRecords) { + this.trackModel(modelName); + + onSnapshot(collectionRef, (querySnapshot) => { + if (this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce) { + querySnapshot.forEach((docSnapshot) => ( + this.store.findRecord(modelName, docSnapshot.id, { + adapterOptions: { isRealtime: true }, + }) + )); + } else { + this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce = true; + } + }, () => { + this.modelTracker[modelName].meta.hasTrackedAllRecords = false; + }); + + this.modelTracker[modelName].meta.hasTrackedAllRecords = true; + this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce = false; + } + } + + public trackFindHasManyChanges( + modelName: string | number, + id: string, + field: string, + collectionRef: CollectionReference | Query, + ): void { + const queryId = `${modelName}_${id}_${field}`; + + if (!Object.prototype.hasOwnProperty.call(this.queryTracker, queryId)) { + this.queryTracker[queryId] = { + hasOnSnapshotRunAtLeastOnce: false, + unsubscribe: () => {}, + }; + } + + const unsubscribe = onSnapshot(collectionRef, () => { + if (this.queryTracker[queryId].hasOnSnapshotRunAtLeastOnce) { + // Schedule for next runloop to avoid race condition errors for when a record is unloaded + // in the find record tracker because it was deleted in the database. Basically, we should + // unload any deleted records first before refreshing the has-many array. + next(() => { + const hasManyRef = this.store.peekRecord(modelName, id).hasMany(field); + + hasManyRef.reload().then(() => this.queryTracker[queryId].unsubscribe()); + }); + } else { + this.queryTracker[queryId].hasOnSnapshotRunAtLeastOnce = true; + } + }, () => delete this.queryTracker[queryId]); + + this.queryTracker[queryId].unsubscribe = unsubscribe; + } + + public trackQueryChanges( + firestoreQuery: Query, + recordArray: DS.AdapterPopulatedRecordArray, + queryId?: string, + ): void { + const finalQueryId = queryId || Math.random().toString(32).slice(2).substr(0, 5); + + if (!Object.prototype.hasOwnProperty.call(this.queryTracker, finalQueryId)) { + this.queryTracker[finalQueryId] = { + hasOnSnapshotRunAtLeastOnce: false, + unsubscribe: () => {}, + }; + } + + const unsubscribe = onSnapshot(firestoreQuery, () => { + if (this.queryTracker[finalQueryId].hasOnSnapshotRunAtLeastOnce) { + // Schedule for next runloop to avoid race condition errors for when a record is unloaded + // in the find record tracker because it was deleted in the database. Basically, we should + // unload any deleted records first before refreshing the query array. + next(() => ( + recordArray.update().then(() => this.queryTracker[finalQueryId].unsubscribe()) + )); + } else { + this.queryTracker[finalQueryId].hasOnSnapshotRunAtLeastOnce = true; + } + }, () => delete this.queryTracker[finalQueryId]); + + this.queryTracker[finalQueryId].unsubscribe = unsubscribe; + } + + private isRecordTracked(modelName: string, id: string): boolean { + return this.modelTracker[modelName]?.record?.[id] !== undefined; + } + + private trackModel(type: string): void { + if (!Object.prototype.hasOwnProperty.call(this.modelTracker, type)) { + this.modelTracker[type] = { + meta: { + hasOnSnapshotRunAtLeastOnce: false, + hasTrackedAllRecords: false, + }, + record: {}, + }; + } + } + + private unloadRecord(modelName: string, id: string): void { + const record = this.store.peekRecord(modelName, id); + + if (record && !record.isSaving) { + this.store.unloadRecord(record); + } + + delete this.modelTracker[modelName].record[id]; + } +} diff --git a/addon/adapters/cloud-firestore-modular.ts b/addon/adapters/cloud-firestore-modular.ts new file mode 100644 index 00000000..9b68171b --- /dev/null +++ b/addon/adapters/cloud-firestore-modular.ts @@ -0,0 +1,429 @@ +/* + eslint + ember/use-ember-data-rfc-395-imports: off, + ember/no-ember-super-in-es-classes: off +*/ + +import { getOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; +import Adapter from '@ember-data/adapter'; +import DS from 'ember-data'; +import RSVP from 'rsvp'; +import Store from '@ember-data/store'; +import classic from 'ember-classic-decorator'; + +import { + CollectionReference, + DocumentReference, + Query, + QuerySnapshot, + WriteBatch, +} from 'firebase/firestore'; +import firebase from 'firebase/compat/app'; + +import { + collection, + doc, + onSnapshot, + query, + where, + writeBatch, +} from 'ember-cloud-firestore-adapter/firebase/firestore'; +import FirebaseService from 'ember-cloud-firestore-adapter/services/-firebase'; +import RealtimeTracker from 'ember-cloud-firestore-adapter/-private/realtime-tracker-modular'; +import buildCollectionName from 'ember-cloud-firestore-adapter/-private/build-collection-name'; +import flattenDocSnapshot from 'ember-cloud-firestore-adapter/-private/flatten-doc-snapshot'; + +interface ModelClass { + modelName: string; +} + +interface AdapterOption { + isRealtime?: boolean; + queryId?: string; + + buildReference?(db: firebase.firestore.Firestore): CollectionReference; + filter?(db: CollectionReference): Query; + include?(batch: WriteBatch, db: firebase.firestore.Firestore): void; +} + +interface Snapshot extends DS.Snapshot { + adapterOptions: AdapterOption; +} + +interface SnapshotRecordArray extends DS.SnapshotRecordArray { + adapterOptions: AdapterOption; +} + +interface BelongsToRelationshipMeta { + type: string; + options: { isRealtime?: boolean }; +} + +interface HasManyRelationshipMeta { + key: string; + type: string; + options: { + isRealtime?: boolean, + + buildReference?(db: firebase.firestore.Firestore, record: unknown): CollectionReference, + filter?(db: CollectionReference | Query, record: unknown): Query, + }; +} + +@classic +export default class CloudFirestoreModularAdapter extends Adapter { + @service('-firebase') + declare protected firebase: FirebaseService; + + protected referenceKeyName = 'referenceTo'; + + declare private realtimeTracker: RealtimeTracker; + + private get isFastBoot(): boolean { + const fastboot = getOwner(this).lookup('service:fastboot'); + + return fastboot && fastboot.isFastBoot; + } + + public init(...args: unknown[]): void { + this._super(...args); + + this.realtimeTracker = new RealtimeTracker(getOwner(this).lookup('service:store')); + } + + public generateIdForRecord(_store: Store, type: string): string { + const db = this.firebase.firestore(); + const collectionName = buildCollectionName(type); + + return doc(collection(db, collectionName)).id; + } + + public createRecord( + store: Store, + type: ModelClass, + snapshot: Snapshot, + ): RSVP.Promise { + return this.updateRecord(store, type, snapshot); + } + + public updateRecord( + _store: Store, + type: ModelClass, + snapshot: Snapshot, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const collectionRef = this.buildCollectionRef(type.modelName, snapshot.adapterOptions); + const docRef = doc(collectionRef, snapshot.id); + const batch = this.buildWriteBatch(docRef, snapshot); + + batch.commit().then(() => { + const data = this.serialize(snapshot, { includeId: true }); + + resolve(data); + + if (snapshot.adapterOptions?.isRealtime && !this.isFastBoot) { + // Setup realtime listener for record + this.fetchRecord(type, snapshot.id, snapshot.adapterOptions); + } + }).catch((e) => { + reject(e); + }); + }); + } + + public deleteRecord( + _store: Store, + type: ModelClass, + snapshot: Snapshot, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const db = this.firebase.firestore(); + const collectionRef = this.buildCollectionRef(type.modelName, snapshot.adapterOptions); + const docRef = doc(collectionRef, snapshot.id); + const batch = writeBatch(db); + + batch.delete(docRef); + this.addIncludeToWriteBatch(batch, snapshot.adapterOptions); + + batch.commit().then(() => { + resolve(); + }).catch((e) => { + reject(e); + }); + }); + } + + public findRecord( + _store: Store, + type: ModelClass, + id: string, + snapshot: Snapshot, + ): RSVP.Promise { + return this.fetchRecord(type, id, snapshot.adapterOptions); + } + + public findAll( + _store: Store, + type: ModelClass, + _sinceToken: string, + snapshotRecordArray?: SnapshotRecordArray, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const db = this.firebase.firestore(); + const collectionRef = collection(db, buildCollectionName(type.modelName)); + const unsubscribe = onSnapshot(collectionRef, async (querySnapshot) => { + if (snapshotRecordArray?.adapterOptions?.isRealtime && !this.isFastBoot) { + this.realtimeTracker?.trackFindAllChanges(type.modelName, collectionRef); + } + + const requests = querySnapshot.docs.map((docSnapshot) => ( + this.fetchRecord(type, docSnapshot.id, snapshotRecordArray?.adapterOptions) + )); + + try { + resolve(await RSVP.Promise.all(requests)); + } catch (error) { + reject(error); + } + + unsubscribe(); + }, (error) => reject(error)); + }); + } + + public findBelongsTo( + _store: Store, + _snapshot: Snapshot, + url: string, + relationship: BelongsToRelationshipMeta, + ): RSVP.Promise { + const type = { modelName: relationship.type }; + const urlNodes = url.split('/'); + const id = urlNodes[urlNodes.length - 1]; + + urlNodes.pop(); + + return this.fetchRecord(type, id, { + isRealtime: relationship.options.isRealtime, + + buildReference(db: firebase.firestore.Firestore) { + return collection(db, urlNodes.join('/')); + }, + }); + } + + public findHasMany( + store: Store, + snapshot: Snapshot, + url: string, + relationship: HasManyRelationshipMeta, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const collectionRef = this.buildHasManyCollectionRef(store, snapshot, url, relationship); + const unsubscribe = onSnapshot(collectionRef, async (querySnapshot) => { + if (relationship.options.isRealtime && !this.isFastBoot) { + this.realtimeTracker?.trackFindHasManyChanges( + snapshot.modelName, + snapshot.id, + relationship.key, + collectionRef, + ); + } + + const requests = this.findHasManyRecords(relationship, querySnapshot); + + try { + resolve(await RSVP.Promise.all(requests)); + } catch (error) { + reject(error); + } + + unsubscribe(); + }, (error) => reject(error)); + }); + } + + public query( + _store: Store, + type: ModelClass, + queryOption: AdapterOption, + recordArray: DS.AdapterPopulatedRecordArray, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const collectionRef = this.buildCollectionRef(type.modelName, queryOption); + const queryRef = queryOption.filter?.(collectionRef) || collectionRef; + const unsubscribe = onSnapshot(queryRef, async (querySnapshot) => { + if (queryOption.isRealtime && !this.isFastBoot) { + this.realtimeTracker?.trackQueryChanges(queryRef, recordArray, queryOption.queryId); + } + + const requests = this.findQueryRecords(type, queryOption, querySnapshot); + + try { + resolve(await RSVP.Promise.all(requests)); + } catch (error) { + reject(error); + } + + unsubscribe(); + }, (error) => reject(error)); + }); + } + + private buildCollectionRef( + modelName: string, + adapterOptions?: AdapterOption, + ): CollectionReference { + const db = this.firebase.firestore(); + + return adapterOptions?.buildReference?.(db) || collection(db, buildCollectionName(modelName)); + } + + private fetchRecord( + type: ModelClass, + id: string, + adapterOption?: AdapterOption, + ): RSVP.Promise { + return new RSVP.Promise((resolve, reject) => { + const collectionRef = this.buildCollectionRef(type.modelName, adapterOption); + const docRef = doc(collectionRef, id); + const unsubscribe = onSnapshot(docRef, (docSnapshot) => { + if (docSnapshot.exists()) { + if (adapterOption?.isRealtime && !this.isFastBoot) { + this.realtimeTracker?.trackFindRecordChanges(type.modelName, docRef); + } + + resolve(flattenDocSnapshot(docSnapshot)); + } else { + reject(new Error(`Record ${id} for model type ${type.modelName} doesn't exist`)); + } + + unsubscribe(); + }, (error) => reject(error)); + }); + } + + private addDocRefToWriteBatch( + batch: WriteBatch, + docRef: DocumentReference, + snapshot: Snapshot, + ): void { + const data = this.serialize(snapshot, {}); + + batch.set(docRef, data, { merge: true }); + } + + private addIncludeToWriteBatch(batch: WriteBatch, adapterOptions?: AdapterOption): void { + const db = this.firebase.firestore(); + + adapterOptions?.include?.(batch, db); + } + + private buildWriteBatch(docRef: DocumentReference, snapshot: Snapshot): WriteBatch { + const db = this.firebase.firestore(); + const batch = writeBatch(db); + + this.addDocRefToWriteBatch(batch, docRef, snapshot); + this.addIncludeToWriteBatch(batch, snapshot.adapterOptions); + + return batch; + } + + private buildHasManyCollectionRef( + store: Store, + snapshot: Snapshot, + url: string, + relationship: HasManyRelationshipMeta, + ): CollectionReference | Query { + const db = this.firebase.firestore(); + + if (relationship.options.buildReference) { + const collectionRef = relationship.options.buildReference(db, snapshot.record); + + return relationship.options.filter?.(collectionRef, snapshot.record) || collectionRef; + } + + const cardinality = snapshot.type.determineRelationshipType(relationship, store); + + if (cardinality === 'manyToOne') { + const inverse = snapshot.type.inverseFor(relationship.key, store); + const snapshotCollectionName = buildCollectionName(snapshot.modelName.toString()); + const snapshotDocRef = doc(db, `${snapshotCollectionName}/${snapshot.id}`); + const collectionRef = collection(db, url); + const queryRef = query(collectionRef, where(inverse.name, '==', snapshotDocRef)); + + return relationship.options.filter?.(queryRef, snapshot.record) || queryRef; + } + + const collectionRef = collection(db, url); + + return relationship.options.filter?.(collectionRef, snapshot.record) || collectionRef; + } + + private findHasManyRecords( + relationship: HasManyRelationshipMeta, + querySnapshot: QuerySnapshot, + ): RSVP.Promise[] { + return querySnapshot.docs.map((docSnapshot) => { + const type = { modelName: relationship.type }; + const referenceTo = docSnapshot.get(this.referenceKeyName); + + if (referenceTo && referenceTo.firestore) { + return this.fetchRecord(type, referenceTo.id, { + isRealtime: relationship.options.isRealtime, + + buildReference(db: firebase.firestore.Firestore) { + return collection(db, referenceTo.parent.path); + }, + }); + } + + const adapterOptions = { + isRealtime: relationship.options.isRealtime, + + buildReference(db: firebase.firestore.Firestore) { + return collection(db, docSnapshot.ref.parent.path); + }, + }; + + return this.fetchRecord(type, docSnapshot.id, adapterOptions); + }); + } + + private findQueryRecords( + type: ModelClass, + option: AdapterOption, + querySnapshot: QuerySnapshot, + ): RSVP.Promise[] { + return querySnapshot.docs.map((docSnapshot) => { + const referenceTo = docSnapshot.get(this.referenceKeyName); + + if (referenceTo && referenceTo.firestore) { + const request = this.fetchRecord(type, referenceTo.id, { + isRealtime: option.isRealtime, + + buildReference() { + return referenceTo.parent; + }, + }); + + return request; + } + + return this.fetchRecord(type, docSnapshot.id, { + ...option, + + buildReference() { + return docSnapshot.ref.parent; + }, + }); + }); + } +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'cloud-firestore-modular': CloudFirestoreModularAdapter; + } +} diff --git a/addon/serializers/cloud-firestore-modular.ts b/addon/serializers/cloud-firestore-modular.ts new file mode 100644 index 00000000..0fde98fd --- /dev/null +++ b/addon/serializers/cloud-firestore-modular.ts @@ -0,0 +1,138 @@ +/* + eslint + @typescript-eslint/ban-types: off, + ember/use-ember-data-rfc-395-imports: off, + no-param-reassign: off, +*/ + +import { inject as service } from '@ember/service'; +import { isNone } from '@ember/utils'; +import DS from 'ember-data'; +import JSONSerializer from '@ember-data/serializer/json'; +import Store from '@ember-data/store'; + +import { CollectionReference, DocumentReference } from 'firebase/firestore'; +import firebase from 'firebase/compat/app'; + +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; +import FirebaseService from 'ember-cloud-firestore-adapter/services/-firebase'; +import buildCollectionName from 'ember-cloud-firestore-adapter/-private/build-collection-name'; + +interface Links { + [key: string]: string; +} + +interface ResourceHash { + id: string; + links: Links; + [key: string]: string | Links | CollectionReference; +} + +interface RelationshipDefinition { + key: string; + type: string; + options: { + buildReference?(db: firebase.firestore.Firestore): CollectionReference + }; +} + +interface ModelClass { + modelName: string; + determineRelationshipType(descriptor: { kind: string, type: string }, store: Store): string; + eachRelationship(callback: (name: string, descriptor: { + kind: string, + type: string, + }) => void): void; +} + +export default class CloudFirestoreSerializer extends JSONSerializer { + @service('-firebase') + private firebase!: FirebaseService; + + public extractRelationship( + relationshipModelName: string, + relationshipHash: DocumentReference, + ): { id: string, type: string } | {} { + if (isNone(relationshipHash)) { + return super.extractRelationship(relationshipModelName, relationshipHash); + } + + const pathNodes = relationshipHash.path.split('/'); + const belongsToId = pathNodes[pathNodes.length - 1]; + + return { id: belongsToId, type: relationshipModelName }; + } + + public extractRelationships(modelClass: ModelClass, resourceHash: ResourceHash): {} { + const newResourceHash = { ...resourceHash }; + const links: { [key: string]: string } = {}; + + modelClass.eachRelationship((name, descriptor) => { + if (descriptor.kind === 'belongsTo') { + if (resourceHash[name]) { + const data = resourceHash[name] as CollectionReference; + + links[name] = data.path; + } + } else { + const cardinality = modelClass.determineRelationshipType(descriptor, this.store); + let hasManyPath; + + if (cardinality === 'manyToOne') { + hasManyPath = buildCollectionName(descriptor.type); + } else { + const collectionName = buildCollectionName(modelClass.modelName); + const docId = resourceHash.id; + + hasManyPath = `${collectionName}/${docId}/${name}`; + } + + links[name] = hasManyPath; + } + }); + + newResourceHash.links = links; + + return super.extractRelationships(modelClass, newResourceHash); + } + + public serializeBelongsTo( + snapshot: DS.Snapshot, + json: { [key: string]: string | null | DocumentReference }, + relationship: RelationshipDefinition, + ): void { + super.serializeBelongsTo(snapshot, json, relationship); + + if (json[relationship.key]) { + const db = this.firebase.firestore(); + const docId = json[relationship.key] as string; + + if (relationship.options.buildReference) { + json[relationship.key] = doc(relationship.options.buildReference(db), docId); + } else { + const collectionName = buildCollectionName(relationship.type); + const path = `${collectionName}/${docId}`; + + json[relationship.key] = doc(db, path); + } + } + } + + public serialize(snapshot: DS.Snapshot, options: {}): {} { + const json: { [key: string]: unknown } = { ...super.serialize(snapshot, options) }; + + snapshot.eachRelationship((name: string, relationship) => { + if (relationship.kind === 'hasMany') { + delete json[name]; + } + }); + + return json; + } +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'cloud-firestore-modular': CloudFirestoreSerializer; + } +} diff --git a/app/adapters/cloud-firestore-modular.js b/app/adapters/cloud-firestore-modular.js new file mode 100644 index 00000000..65e4087f --- /dev/null +++ b/app/adapters/cloud-firestore-modular.js @@ -0,0 +1 @@ +export { default } from 'ember-cloud-firestore-adapter/adapters/cloud-firestore-modular'; diff --git a/app/serializers/cloud-firestore-modular.js b/app/serializers/cloud-firestore-modular.js new file mode 100644 index 00000000..ecab600c --- /dev/null +++ b/app/serializers/cloud-firestore-modular.js @@ -0,0 +1 @@ +export { default } from 'ember-cloud-firestore-adapter/serializers/cloud-firestore-modular'; diff --git a/docs/authentication.md b/docs/authentication.md index ef4dc1ba..bd7d065a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -6,7 +6,7 @@ The adapter supports Firebase authentication through the [ember-simple-auth](htt Using the `session` service provided by ember-simple-auth, a callback must be passed-in which will be responsible for authenticating the user. -The callback will have the [`firebase.auth.Auth`](https://firebase.google.com/docs/reference/js/firebase.auth.Auth) as a param. Use this to authenticate the user using any of the providers available. It **must** also return a promise that will resolve to an instance of [`firebase.auth.UserCredential`](https://firebase.google.com/docs/reference/js/v8/firebase.auth#usercredential). +The callback will have the [`firebase.auth.Auth`](https://firebase.google.com/docs/reference/js/firebase.auth.Auth) as a param. Use this to authenticate the user using any of the providers available. It **must** also return a promise that will resolve to an instance of [`UserCredential`](https://firebase.google.com/docs/reference/js/auth.usercredential). ```javascript import { signInWithEmailAndPassword } from 'ember-cloud-firestore-adapter/firebase/auth'; diff --git a/docs/create-update-delete-records.md b/docs/create-update-delete-records.md index 8eeb6a60..8ae44149 100644 --- a/docs/create-update-delete-records.md +++ b/docs/create-update-delete-records.md @@ -9,6 +9,8 @@ The optional configs are available through the `adapterOptions` property in the e.g. ```javascript +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; + const newPost = this.store.createRecord('post', { title: 'Post A' }); newPost.save({ @@ -16,7 +18,7 @@ newPost.save({ isRealtime: true, include(batch, db) { - batch.set(db.collection('users').doc('user_b').collection('feeds').doc('feed_a'), { title: 'Post A' }); + batch.set(doc(db, 'users/user_b/feeds/feed_a'), { title: 'Post A' }); } } }); @@ -50,7 +52,7 @@ Hook for providing additional documents to batch write | Name | Type | Description | | ----- | -------------------------------------------------------------------------------------------------------------- | ----------- | -| batch | [`firebase.firestore.WriteBatch`](https://firebase.google.com/docs/reference/js/firebase.firestore.WriteBatch) | | +| batch | [`WriteBatch`](https://firebase.google.com/docs/reference/js/firestore_.writebatch) | | | db | [`firebase.firestore.Firestore`](https://firebase.google.com/docs/reference/js/firebase.firestore.Firestore) | | ## `deleteRecord` @@ -60,11 +62,13 @@ The optional configs are available through the `adapterOptions` property in the e.g. ```javascript +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; + user.deleteRecord(); user.save({ adapterOptions: { include(batch, db) { - batch.delete(db.collection('usernames').doc(newUser.id)); + batch.delete(doc(db, `usernames/${newUser.id}`)); } } }); @@ -92,7 +96,7 @@ Hook for providing additional documents to batch write | Name | Type | Description | | ----- | -------------------------------------------------------------------------------------------------------------- | ----------- | -| batch | [`firebase.firestore.WriteBatch`](https://firebase.google.com/docs/reference/js/firebase.firestore.WriteBatch) | | +| batch | [`WriteBatch`](https://firebase.google.com/docs/reference/js/firestore_.writebatch) | | | db | [`firebase.firestore.Firestore`](https://firebase.google.com/docs/reference/js/firebase.firestore.Firestore) | | ## `destroyRecord` @@ -102,10 +106,12 @@ The optional configs are available through the `adapterOptions` property. e.g. ```javascript +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; + user.destroyRecord({ adapterOptions: { include(batch, db) { - batch.delete(db.collection('usernames').doc(newUser.id)); + batch.delete(doc(db, `usernames/${newUser.id}`)); } } }); @@ -133,7 +139,7 @@ Hook for providing additional documents to batch write | Name | Type | Description | | ----- | -------------------------------------------------------------------------------------------------------------- | ----------- | -| batch | [`firebase.firestore.WriteBatch`](https://firebase.google.com/docs/reference/js/firebase.firestore.WriteBatch) | | +| batch | [`WriteBatch`](https://firebase.google.com/docs/reference/js/firestore_.writebatch) | | | db | [`firebase.firestore.Firestore`](https://firebase.google.com/docs/reference/js/firebase.firestore.Firestore) | | ## Updating a record @@ -143,13 +149,15 @@ The optional configs are available through the `adapterOptions` property in the e.g. ```javascript +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; + post.set('title', 'New Title'); post.save({ adapterOptions: { isRealtime: true, include(batch, db) { - batch.update(db.collection('users').doc('user_b').collection('feeds').doc('feed_a'), { title: 'New Title' }); + batch.update(doc(db, 'users/user_b/feeds/feed_a'), { title: 'New Title' }); } } }); @@ -183,7 +191,7 @@ Hook for providing additional documents to batch write | Name | Type | Description | | ----- | -------------------------------------------------------------------------------------------------------------- | ----------- | -| batch | [`firebase.firestore.WriteBatch`](https://firebase.google.com/docs/reference/js/firebase.firestore.WriteBatch) | | +| batch | [`WriteBatch`](https://firebase.google.com/docs/reference/js/firestore_.writebatch) | | | db | [`firebase.firestore.Firestore`](https://firebase.google.com/docs/reference/js/firebase.firestore.Firestore) | | ## Saving relationships @@ -202,27 +210,31 @@ When saving a one-to-many relationship, the reference will be persisted on the b In this scenario, `User` model has a many-to-many relationship with `Group` model through the `groups` and `members` field name respectively. ```javascript -// Assume that a someGroup variable already exists +import { doc } from 'ember-cloud-firestore-adapter/firebase/firestore'; + +// Assume that a group1 variable already exists ... const newUser = this.store.createRecord('user', { name: 'Foo', - groups: [someGroup] + groups: [group1] }); newUser.save({ adapterOptions: { include(batch, db) { // Batch write to the users//groups sub-collection - batch.set(db.collection('users').doc(newUser.get('id')).collection('groups').doc(someGroup.get('id')), { - referenceTo: db.collection('groups').doc(someGroup.get('id')) - }); + batch.set( + doc(db, `users/${newUser.get('id')}/groups/${group1.get('id')}`), + { referenceTo: doc(db, `groups/${group1.get('id')}`) } + ); // Batch write to the groups//members sub-collection - batch.set(db.collection('groups').doc(someGroup.get('id')).collection('members').doc(newUser.get('id')), { - referenceTo: db.collection('users').doc(newUser.get('id')) - }); + batch.set( + doc(db, `groups/${group1.get('id')}/members/${newUser.get('id')}`), + { referenceTo: db.collection('users').doc(newUser.get('id')) } + ); } } }); @@ -239,7 +251,9 @@ e.g. In this scenario, the `User` model has a many-to-none relationship with `Reminder` model. ```javascript -// Assume that a someUser variable already exists +import { collection } from 'ember-cloud-firestore-adapter/firebase/firestore'; + +// Assume that a user1 variable already exists ... @@ -250,12 +264,12 @@ const reminder = this.store.createRecord('reminder', { reminder.save({ adapterOptions: { buildReference(db) { - return db.collection('users').doc(someUser.get('id')).collection('reminders'); + return collection(db, `users/${user1.get('id')}/reminders`); } } }).then(() => { - // Update reminders hasMany without flagging someUser as "dirty" or unsaved - someUser.hasMany('reminders').push({ + // Update reminders hasMany without flagging user1 as "dirty" or unsaved + user1.hasMany('reminders').push({ type: 'reminder', id: reminder.get('id') }); diff --git a/docs/finding-records.md b/docs/finding-records.md index 22fa4573..24c07bac 100644 --- a/docs/finding-records.md +++ b/docs/finding-records.md @@ -9,12 +9,14 @@ The optional configs are available through the `adapterOptions` property. e.g. ```javascript +import { collection } from 'ember-cloud-firestore-adapter/firebase/firestore'; + this.store.findRecord('post', 'post_a', { adapterOptions: { isRealtime: true, buildReference(db) { - return db.collection('users').doc('user_a').collection('feeds'); + return collection(db, 'users/user_a/feeds'); } } }); @@ -65,16 +67,23 @@ The optional configs are available through the query param. e.g. ```javascript +import { + collection, + limit, + query, + where +} from 'ember-cloud-firestore-adapter/firebase/firestore'; + this.store.query('post', { isRealtime: true, queryId: 'foobar', buildReference(db) { - return db.collection('users').doc('user_a').collection('feeds'); + return collection(db, 'users/user_a/feeds'); }, filter(reference) { - return reference.where('likes', '>=', 100).limit(5); + return query(reference, where('likes' '>=' 100), limit(5)); } }); ``` @@ -117,7 +126,7 @@ Hook for providing the query for the collection reference | Name | Type | Description | | --------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| reference | [`firebase.firestore.CollectionReference`](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference) | Will contain the return of `buildReference` when overriden. Otherwise, it'll be provided by the adapter itself. | +| reference | [`CollectionReference`](https://firebase.google.com/docs/reference/js/firestore_.collectionreference) | Will contain the return of `buildReference` when overriden. Otherwise, it'll be provided by the adapter itself. | --- diff --git a/docs/getting-started.md b/docs/getting-started.md index d04ff7dc..5035854e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,7 +56,7 @@ This contains the settings related to Auth. The available properties are: - `emulator` (optional) - Use this object property if you want to use [Firebase Emulator](https://firebase.google.com/docs/emulator-suite) for your local development. The available properties are `hostname` and `port`. -### 2. Create Your Application Adapter +## 2. Create Your Application Adapter Create an application adapter by running: @@ -67,20 +67,24 @@ ember generate adapter application Change it to look something like this: ```javascript -import CloudFirestoreAdapter from 'ember-cloud-firestore-adapter/adapters/cloud-firestore'; +import CloudFirestoreAdapter from 'ember-cloud-firestore-adapter/adapters/cloud-firestore-modular'; export default class ApplicationAdapter extends CloudFirestoreAdapter { referenceKeyName = 'foobar'; } ``` -#### Adapter Settings +### Adapter Settings These are the settings currently available: - `referenceKeyName` - Name of the field that will indicate whether a document is a reference to another one. (Defaults to `'referenceTo'`) -### 3. Create Your Application Serializer +> **NOTE:** This addon is in a transition phase towards Firebase Modular SDK. In order to support it alongside the Compat SDK, we've created a new `cloud-firestore-modular` adapter. Compat SDK adapter is now in maintenance mode until Firebase v10 so we recommend everyone to use the Modular SDK adapter instead moving forward. +> +> To view the Compat SDK docs, click [here](https://github.com/mikkopaderes/ember-cloud-firestore-adapter/blob/v2.0.2/docs/getting-started.md). + +## 3. Create Your Application Serializer Create an application serializer by running: @@ -91,11 +95,34 @@ ember generate serializer application Change it to look something like this: ```javascript -import CloudFirestoreSerializer from 'ember-cloud-firestore-adapter/serializers/cloud-firestore'; +import CloudFirestoreSerializer from 'ember-cloud-firestore-adapter/serializers/cloud-firestore-modular'; export default class ApplicationSerializer extends CloudFirestoreSerializer { } ``` +> **NOTE:** This addon is in a transition phase towards Firebase Modular SDK. In order to support it alongside the Compat SDK, we've created a new `cloud-firestore-modular` serializer. Compat SDK serializer is now in maintenance mode until Firebase v10 so we recommend everyone to use the Modular SDK serializer instead moving forward. +> +> To view the Compat SDK docs, click [here](https://github.com/mikkopaderes/ember-cloud-firestore-adapter/blob/v2.0.2/docs/getting-started.md). + +## 4. Firebase and Auth Modular API Imports + +In order to support FastBoot, we've created wrapper imports for the Modular API functions which you can source out from `ember-cloud-firestore-adapter/firebase/` respectively. + +e.g + +```javascript +import { signInWithEmailAndPassword } from 'ember-cloud-firestore-adapter/firebase/auth'; +import { doc, getDoc } from 'ember-cloud-firestore-adapter/firebase/firestore'; +``` + +Note that only function types are wrapped. Variables, class, interface, etc. must still be imported from Firebase paths. + +e.g. + +```javascript +import { CollectionReference } from 'firebase/firestore'; +``` + --- [Next: Data Structure ยป](data-structure.md) diff --git a/docs/relationships.md b/docs/relationships.md index 5c636504..8b58fa3f 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -40,6 +40,8 @@ The optional configs are available by passing it as a param. ```javascript import Model, { attr, hasMany } from '@ember-data/model'; +import { query, where } from 'ember-cloud-firestore-adapter/firebase/firestore'; + export default class GroupModel extends Model { @attr name; @@ -47,7 +49,7 @@ export default class GroupModel extends Model { isRealtime: true, filter(reference) { - return reference.where('status', '==', 'approved'); + return query(reference, where('status', '==', 'approved')); } }) approvedPosts; @@ -84,7 +86,7 @@ Hook for providing the query for the collection reference | Name | Type | Description | | --------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| reference | [`firebase.firestore.CollectionReference`](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference) | Will contain the return of `buildReference` when overriden. Otherwise, it will be provided by the adapter itself. | +| reference | [`CollectionReference`](https://firebase.google.com/docs/reference/js/firestore_.collectionreference) | Will contain the return of `buildReference` when overriden. Otherwise, it will be provided by the adapter itself. | | record | Object | The record itself | --- diff --git a/docs/transforms.md b/docs/transforms.md index 15819476..a44ca66b 100644 --- a/docs/transforms.md +++ b/docs/transforms.md @@ -2,7 +2,7 @@ ## Timestamp -Timestamp transform is provided as a convenience to [`firebase.firestore.FieldValue.serverTimestamp()`](https://firebase.google.com/docs/reference/js/firebase.firestore.FieldValue#servertimestamp). +Timestamp transform is provided as a convenience to [`serverTimestamp()`](https://firebase.google.com/docs/reference/js/firestore_.md#servertimestamp). ```javascript import Model, { attr } from '@ember-data/model'; @@ -13,7 +13,7 @@ export default class PostModel extends Model { } ``` -In the example above, whenever you save a record where the value of `createdOn` is of a `Date` instance, it will use that value as-is. Otherwise, it wll use `firebase.firestore.FieldValue.serverTimestamp()`. +In the example above, whenever you save a record where the value of `createdOn` is of a `Date` instance, it will use that value as-is. Otherwise, it wll use `serverTimestamp()`. --- diff --git a/tests/dummy/app/adapters/application.ts b/tests/dummy/app/adapters/application.ts index 57e21eae..4c7732da 100644 --- a/tests/dummy/app/adapters/application.ts +++ b/tests/dummy/app/adapters/application.ts @@ -1,4 +1,4 @@ -import CloudFirestoreAdapter from 'ember-cloud-firestore-adapter/adapters/cloud-firestore'; +import CloudFirestoreAdapter from 'ember-cloud-firestore-adapter/adapters/cloud-firestore-modular'; export default class ApplicationAdapter extends CloudFirestoreAdapter { } diff --git a/tests/dummy/app/controllers/features.ts b/tests/dummy/app/controllers/features.ts index b6b57ece..22beb0b9 100644 --- a/tests/dummy/app/controllers/features.ts +++ b/tests/dummy/app/controllers/features.ts @@ -5,6 +5,7 @@ import EmberArray from '@ember/array'; import firebase from 'firebase/compat/app'; +import { collection, query, where } from 'ember-cloud-firestore-adapter/firebase/firestore'; import UserModel from '../models/user'; export default class FeaturesController extends Controller { @@ -96,8 +97,12 @@ export default class FeaturesController extends Controller { @action public async handleQuery1Click(): Promise { const users = await this.store.query('user', { - filter(reference: firebase.firestore.Query) { - return reference.where('age', '>=', 15); + buildReference(db: firebase.firestore.Firestore) { + return collection(db, 'users'); + }, + + filter(reference: firebase.firestore.CollectionReference) { + return query(reference, where('age', '>=', 15)); }, }); diff --git a/tests/dummy/app/models/group.ts b/tests/dummy/app/models/group.ts index c09a2368..4ef38852 100644 --- a/tests/dummy/app/models/group.ts +++ b/tests/dummy/app/models/group.ts @@ -8,8 +8,9 @@ import DS from 'ember-data'; import Model, { attr, hasMany } from '@ember-data/model'; -import firebase from 'firebase/compat/app'; +import { Query } from 'firebase/firestore'; +import { limit, query } from 'ember-cloud-firestore-adapter/firebase/firestore'; import PostModel from './post'; import UserModel from './user'; @@ -22,8 +23,8 @@ export default class GroupModel extends Model { @hasMany('post', { // @ts-ignore: TODO - find a way to set custom property in RelationshipOptions interface - filter(reference: firebase.firestore.Query) { - return reference.limit(1); + filter(reference: Query) { + return query(reference, limit(1)); }, }) declare public posts: DS.PromiseManyArray; diff --git a/tests/dummy/app/models/post.ts b/tests/dummy/app/models/post.ts index 997aa551..74f1c1bf 100644 --- a/tests/dummy/app/models/post.ts +++ b/tests/dummy/app/models/post.ts @@ -10,6 +10,7 @@ import Model, { attr, belongsTo } from '@ember-data/model'; import firebase from 'firebase/compat/app'; +import { collection } from 'ember-cloud-firestore-adapter/firebase/firestore'; import TimestampTransform from 'ember-cloud-firestore-adapter/transforms/timestamp'; import GroupModel from './group'; import UserModel from './user'; @@ -32,7 +33,7 @@ export default class PostModel extends Model { // @ts-ignore: TODO - find a way to set custom property in RelationshipOptions interface buildReference(db: firebase.firestore.Firestore) { - return db.collection('publishers'); + return collection(db, 'publishers'); }, }) declare public publisher: DS.PromiseObject; diff --git a/tests/dummy/app/serializers/application.ts b/tests/dummy/app/serializers/application.ts index e9cfc1e2..84cce071 100644 --- a/tests/dummy/app/serializers/application.ts +++ b/tests/dummy/app/serializers/application.ts @@ -1,4 +1,4 @@ -import CloudFirestoreSerializer from 'ember-cloud-firestore-adapter/serializers/cloud-firestore'; +import CloudFirestoreSerializer from 'ember-cloud-firestore-adapter/serializers/cloud-firestore-modular'; export default class ApplicationSerializer extends CloudFirestoreSerializer { } diff --git a/tests/unit/-private/realtime-tracker-modular-test.ts b/tests/unit/-private/realtime-tracker-modular-test.ts new file mode 100644 index 00000000..c1842350 --- /dev/null +++ b/tests/unit/-private/realtime-tracker-modular-test.ts @@ -0,0 +1,326 @@ +/* eslint ember/use-ember-data-rfc-395-imports: off */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import ArrayProxy from '@ember/array/proxy'; +import DS from 'ember-data'; +import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; +import RSVP from 'rsvp'; + +import firebase from 'firebase/compat/app'; +import sinon from 'sinon'; + +import { + collection, + deleteDoc, + doc, + query, + setDoc, + updateDoc, + where, +} from 'ember-cloud-firestore-adapter/firebase/firestore'; +import RealtimeTracker from 'ember-cloud-firestore-adapter/-private/realtime-tracker-modular'; +import resetFixtureData from '../../helpers/reset-fixture-data'; + +module('Unit | -Private | realtime-tracker-modular', function (hooks) { + let db: firebase.firestore.Firestore; + + setupTest(hooks); + + hooks.beforeEach(async function () { + db = this.owner.lookup('service:-firebase').firestore(); + + await resetFixtureData(db); + }); + + hooks.afterEach(async function () { + await resetFixtureData(db); + }); + + module('trackFindRecordChanges()', function () { + test('should not push record to store when called for the first time', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + + const realtimeTracker = new RealtimeTracker(store); + const docRef = doc(db, 'users/user_a'); + + // Act + realtimeTracker.trackFindRecordChanges('user', docRef); + + // Assert + setTimeout(() => { + assert.equal(store.peekRecord('user', 'user_a'), null); + done(); + }, 500); + }); + + test('should push record to store when an update has been detected', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const realtimeTracker = new RealtimeTracker(store); + const docRef = doc(db, 'users/user_a'); + const newName = Math.random(); + const storeFixture = { + data: { + id: 'user_a', + type: 'user', + attributes: { + name: 'user_a', + }, + relationships: { + groups: { + links: { + related: 'users/user_a/groups', + }, + }, + posts: { + links: { + related: 'posts', + }, + }, + friends: { + links: { + related: 'users/user_a/friends', + }, + }, + }, + }, + }; + + store.push(storeFixture); + + // Act + realtimeTracker.trackFindRecordChanges('user', docRef); + + setTimeout(async () => { + await updateDoc(docRef, { name: newName }); + + // Assert + setTimeout(() => { + assert.equal(store.peekRecord('user', 'user_a').name, newName); + done(); + }, 500); + }, 500); + }); + + test('should unload record from store when a delete has been detected', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const realtimeTracker = new RealtimeTracker(store); + const docRef = doc(db, 'users/user_a'); + const storeFixture = { + data: { + id: 'user_a', + type: 'user', + attributes: { + name: 'user_a', + }, + relationships: { + groups: { + links: { + related: 'users/user_a/groups', + }, + }, + posts: { + links: { + related: 'posts', + }, + }, + friends: { + links: { + related: 'users/user_a/friends', + }, + }, + }, + }, + }; + + store.push(storeFixture); + + // Act + realtimeTracker.trackFindRecordChanges('user', docRef); + + setTimeout(async () => { + await deleteDoc(docRef); + + // Assert + setTimeout(() => { + assert.equal(store.peekRecord('user', 'user_a'), null); + done(); + }, 500); + }, 500); + }); + }); + + module('trackFindAllChanges()', function () { + test('should not find individual records via store when called for the first time', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const findRecordStub = sinon.stub(store, 'findRecord'); + const realtimeTracker = new RealtimeTracker(store); + const collectionRef = collection(db, 'users'); + + // Act + realtimeTracker.trackFindAllChanges('user', collectionRef); + + // Assert + setTimeout(() => { + assert.ok(findRecordStub.notCalled); + done(); + }, 500); + }); + + test('should find individual records via store when an update has been detected', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const findRecordStub = sinon.stub(store, 'findRecord'); + const realtimeTracker = new RealtimeTracker(store); + const collectionRef = collection(db, 'users'); + + // Act + realtimeTracker.trackFindAllChanges('user', collectionRef); + + setTimeout(async () => { + await setDoc(doc(db, 'users/new_user'), { name: 'new_user' }); + + // Assert + setTimeout(() => { + assert.ok(findRecordStub.called); + done(); + }, 500); + }, 500); + }); + }); + + module('trackFindHasManyChanges()', function () { + test('should not reload has many reference when called for the first time', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const reloadStub = sinon.stub().returns(Promise.resolve()); + + sinon.stub(store, 'peekRecord').withArgs('user', 'user_a').returns({ + hasMany: sinon.stub().withArgs('groups').returns({ reload: reloadStub }), + }); + + const realtimeTracker = new RealtimeTracker(store); + const collectionRef = collection(db, 'groups'); + + // Act + realtimeTracker.trackFindHasManyChanges('user', 'user_a', 'groups', collectionRef); + + // Assert + setTimeout(() => { + assert.ok(reloadStub.notCalled); + done(); + }, 500); + }); + + test('should reload has many reference when an update was detected', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const reloadStub = sinon.stub().returns(Promise.resolve()); + + sinon.stub(store, 'peekRecord').withArgs('user', 'user_a').returns({ + hasMany: sinon.stub().withArgs('groups').returns({ reload: reloadStub }), + }); + + const realtimeTracker = new RealtimeTracker(store); + const collectionRef = collection(db, 'groups'); + + // Act + realtimeTracker.trackFindHasManyChanges('user', 'user_a', 'groups', collectionRef); + + setTimeout(async () => { + await setDoc(doc(db, 'groups/new_group'), { name: 'new_group' }); + + // Assert + setTimeout(() => { + assert.ok(reloadStub.called); + done(); + }, 500); + }, 500); + }); + }); + + module('trackQueryChanges()', function () { + test('should not update record array when called for the first time', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const recordArray = {} as DS.AdapterPopulatedRecordArray; + const promiseArray = ArrayProxy.extend(PromiseProxyMixin); + const updateStub = sinon.stub().returns( + promiseArray.create({ promise: RSVP.Promise.resolve() }), + ); + + recordArray.update = updateStub; + + const realtimeTracker = new RealtimeTracker(store); + const queryRef = query(collection(db, 'groups'), where('name', '==', 'new_group')); + + // Act + realtimeTracker.trackQueryChanges(queryRef, recordArray); + + // Assert + setTimeout(() => { + assert.ok(updateStub.notCalled); + done(); + }, 500); + }); + + test('should update record array when an update was detected', async function (assert) { + // Arrange + assert.expect(1); + + const done = assert.async(); + const store = this.owner.lookup('service:store'); + const recordArray = {} as DS.AdapterPopulatedRecordArray; + const promiseArray = ArrayProxy.extend(PromiseProxyMixin); + const updateStub = sinon.stub().returns( + promiseArray.create({ promise: RSVP.Promise.resolve() }), + ); + + recordArray.update = updateStub; + + const realtimeTracker = new RealtimeTracker(store); + const queryRef = query(collection(db, 'groups'), where('name', '==', 'new_group')); + + // Act + realtimeTracker.trackQueryChanges(queryRef, recordArray); + + setTimeout(async () => { + await db.doc('groups/new_group').set({ name: 'new_group' }); + + // Assert + setTimeout(() => { + assert.ok(updateStub.called); + done(); + }, 500); + }, 500); + }); + }); +}); diff --git a/tests/unit/-private/realtime-tracker-test.ts b/tests/unit/-private/realtime-tracker-test.ts index 6acc6687..c68a4857 100644 --- a/tests/unit/-private/realtime-tracker-test.ts +++ b/tests/unit/-private/realtime-tracker-test.ts @@ -271,7 +271,7 @@ module('Unit | -Private | realtime-tracker', function (hooks) { recordArray.update = updateStub; const realtimeTracker = new RealtimeTracker(store); - const query = db.collection('groups').where('name', '==', 'group_a'); + const query = db.collection('groups').where('name', '==', 'new_group'); // Act realtimeTracker.trackQueryChanges(query, recordArray); diff --git a/tests/unit/adapters/cloud-firestore-modular-test.ts b/tests/unit/adapters/cloud-firestore-modular-test.ts new file mode 100644 index 00000000..8ec54d98 --- /dev/null +++ b/tests/unit/adapters/cloud-firestore-modular-test.ts @@ -0,0 +1,537 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import EmberObject from '@ember/object'; + +import { CollectionReference, Firestore, WriteBatch } from 'firebase/firestore'; +import firebase from 'firebase/compat/app'; +import sinon from 'sinon'; + +import { + collection, + doc, + getDoc, + limit, + query, + where, +} from 'ember-cloud-firestore-adapter/firebase/firestore'; +import resetFixtureData from '../../helpers/reset-fixture-data'; + +module('Unit | Adapter | cloud firestore modular', function (hooks) { + let db: firebase.firestore.Firestore; + + setupTest(hooks); + + hooks.beforeEach(async function () { + db = this.owner.lookup('service:-firebase').firestore(); + + await resetFixtureData(db); + }); + + hooks.afterEach(async function () { + await resetFixtureData(db); + }); + + module('function: generateIdForRecord', function () { + test('should generate ID for record', function (assert) { + // Arrange + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = adapter.generateIdForRecord({}, 'foo'); + + // Assert + assert.equal(typeof result, 'string'); + }); + }); + + module('function: createRecord', function () { + test('should proxy a call to updateRecord and return with the created doc', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { id: 'user_100', age: 30, username: 'user_100' }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + const updateRecordStub = sinon.stub(adapter, 'updateRecord').returns('foo'); + + // Act + const result = await adapter.createRecord(store, modelClass, snapshot); + + // Assert + assert.equal(result, 'foo'); + assert.ok(updateRecordStub.calledWithExactly(store, modelClass, snapshot)); + }); + }); + + module('function: updateRecord', function () { + test('should update record and resolve with the updated doc', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { + id: 'user_a', + age: 50, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + adapter.serialize = sinon.stub().returns({ + age: 50, + username: 'user_a', + }); + + // Act + const result = await adapter.updateRecord(store, modelClass, snapshot); + + // Assert + assert.deepEqual(result, { age: 50, username: 'user_a' }); + + const userA = await getDoc(doc(db, 'users/user_a')); + + assert.equal(userA.get('age'), 50); + assert.equal(userA.get('username'), 'user_a'); + }); + + test('should update record in a custom collection and resolve with the updated resource', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { + id: 'user_a', + age: 50, + adapterOptions: { + buildReference(firestore: Firestore) { + return collection(firestore, 'foobar'); + }, + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + adapter.serialize = sinon.stub().returns({ age: 50 }); + + // Act + const result = await adapter.createRecord(store, modelClass, snapshot); + + // Assert + assert.deepEqual(result, { age: 50 }); + + const user100 = await getDoc(doc(db, 'foobar/user_a')); + + assert.deepEqual(user100.data(), { age: 50 }); + }); + + test('should update record and process additional batched writes', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { + id: 'user_a', + age: 50, + adapterOptions: { + include(batch: WriteBatch, firestore: Firestore) { + batch.set(doc(firestore, 'users/user_100'), { age: 60 }); + }, + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + adapter.generateIdForRecord = sinon.stub().returns('12345'); + adapter.serialize = sinon.stub().returns({ age: 50, username: 'user_a' }); + + // Act + const result = await adapter.updateRecord(store, modelClass, snapshot); + + // Assert + assert.deepEqual(result, { age: 50, username: 'user_a' }); + + const userA = await getDoc(doc(db, 'users/user_a')); + + assert.deepEqual(userA.data(), { age: 50, name: 'user_a', username: 'user_a' }); + + const user100 = await getDoc(doc(db, 'users/user_100')); + + assert.deepEqual(user100.data(), { age: 60 }); + }); + }); + + module('function: deleteRecord', function () { + test('should delete record', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { id: 'user_a' }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + await adapter.deleteRecord(store, modelClass, snapshot); + + // Assert + const userA = await getDoc(doc(db, 'users/user_a')); + + assert.notOk(userA.exists()); + }); + + test('should delete record in a custom collection', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'post' }; + const snapshot = { + id: 'post_b', + + adapterOptions: { + buildReference(firestore: Firestore) { + return collection(firestore, 'users/user_a/feeds'); + }, + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + await adapter.deleteRecord(store, modelClass, snapshot); + + // Assert + const postB = await getDoc(doc(db, 'users/user_a/feeds/post_b')); + + assert.notOk(postB.exists()); + }); + + test('should delete record and process additional batched writes', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const snapshot = { + id: 'user_a', + adapterOptions: { + include(batch: WriteBatch, firestore: Firestore) { + batch.delete(doc(firestore, 'users/user_b')); + }, + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + adapter.serialize = sinon.stub().returns({ age: 50, username: 'user_a' }); + + // Act + await adapter.deleteRecord(store, modelClass, snapshot); + + // Assert + const userA = await getDoc(doc(db, 'users/user_a')); + + assert.notOk(userA.exists()); + + const userB = await getDoc(doc(db, 'users/user_b')); + + assert.notOk(userB.exists()); + }); + }); + + module('function: findAll', function () { + test('should fetch all records for a model', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const modelClass = { modelName: 'user' }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findAll(store, modelClass); + + // Assert + assert.deepEqual(result, [ + { + id: 'user_a', + age: 15, + name: 'user_a', + username: 'user_a', + }, + { + id: 'user_b', + age: 10, + name: 'user_b', + username: 'user_b', + }, + { + id: 'user_c', + age: 20, + name: 'user_c', + username: 'user_c', + }, + ]); + }); + }); + + module('function: findRecord', function () { + test('should fetch a record', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const modelClass = { modelName: 'user' }; + const modelId = 'user_a'; + const snapshot = {}; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findRecord(store, modelClass, modelId, snapshot); + + // Assert + assert.deepEqual(result, { + id: 'user_a', + age: 15, + name: 'user_a', + username: 'user_a', + }); + }); + + test('should fetch a record in a custom collection', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const modelClass = { modelName: 'user' }; + const modelId = 'user_a'; + const snapshot = { + adapterOptions: { + buildReference(firestore: Firestore) { + return collection(firestore, 'admins'); + }, + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findRecord(store, modelClass, modelId, snapshot); + + // Assert + assert.deepEqual(result, { id: 'user_a', since: 2010 }); + }); + + test('should throw an error when record does not exists', async function (assert) { + // Arrange + assert.expect(1); + + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const modelClass = { modelName: 'user' }; + const modelId = 'user_100'; + const snapshot = {}; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + try { + // Act + await adapter.findRecord(store, modelClass, modelId, snapshot); + } catch (error) { + // Assert + assert.equal(error.message, 'Record user_100 for model type user doesn\'t exist'); + } + }); + }); + + module('function: findBelongsTo', function () { + test('should fetch a belongs to record', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const snapshot = {}; + const url = 'users/user_a'; + const relationship = { type: 'user', options: {} }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findBelongsTo(store, snapshot, url, relationship); + + // Assert + assert.deepEqual(result, { + id: 'user_a', + age: 15, + name: 'user_a', + username: 'user_a', + }); + }); + }); + + module('function: findHasMany', function () { + test('should fetch many-to-one cardinality', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const determineRelationshipTypeStub = sinon.stub().returns('manyToOne'); + const inverseForStub = sinon.stub().returns({ name: 'author' }); + const snapshot = { + id: 'user_a', + modelName: 'user', + record: EmberObject.create(), + type: { + determineRelationshipType: determineRelationshipTypeStub, + inverseFor: inverseForStub, + }, + }; + const url = 'posts'; + const relationship = { + key: 'posts', + options: { + filter(reference: CollectionReference) { + return query(reference, limit(1)); + }, + }, + type: 'post', + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findHasMany(store, snapshot, url, relationship); + + // Assert + assert.equal(result[0].id, 'post_a'); + assert.equal(result[0].title, 'post_a'); + assert.ok(determineRelationshipTypeStub.calledWithExactly(relationship, store)); + assert.ok(inverseForStub.calledWithExactly(relationship.key, store)); + }); + + test('should fetch many-to-whatever cardinality', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const determineRelationshipTypeStub = sinon.stub().returns('manyToNone'); + const snapshot = { + record: EmberObject.create({ + referenceTo: doc(db, 'users/user_a'), + }), + type: { + determineRelationshipType: determineRelationshipTypeStub, + }, + }; + const url = 'users/user_a/friends'; + const relationship = { + options: { + filter(reference: CollectionReference) { + return query(reference, limit(1)); + }, + }, + type: 'user', + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findHasMany(store, snapshot, url, relationship); + + // Assert + assert.deepEqual(result, [ + { + id: 'user_b', + age: 10, + name: 'user_b', + username: 'user_b', + }, + ]); + assert.ok(determineRelationshipTypeStub.calledWithExactly(relationship, store)); + }); + + test('should be able to fetch with filter using a record property', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const determineRelationshipTypeStub = sinon.stub().returns('manyToOne'); + const inverseForStub = sinon.stub().returns({ name: 'author' }); + const snapshot = { + id: 'user_a', + modelName: 'user', + record: EmberObject.create({ + id: 'user_a', + }), + type: { + determineRelationshipType: determineRelationshipTypeStub, + inverseFor: inverseForStub, + }, + }; + const url = 'posts'; + const relationship = { + key: 'posts', + options: { + filter(reference: CollectionReference, record: { id: string }) { + return query(reference, where('approvedBy', '==', record.id)); + }, + }, + type: 'post', + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findHasMany(store, snapshot, url, relationship); + + // Assert + assert.equal(result.length, 1); + assert.equal(result[0].id, 'post_a'); + assert.ok(determineRelationshipTypeStub.calledWithExactly(relationship, store)); + assert.ok(inverseForStub.calledWithExactly(relationship.key, store)); + }); + + test('should be able to fetch with a custom reference', async function (assert) { + // Arrange + const store = { normalize: sinon.stub(), push: sinon.stub() }; + const snapshot = { + record: EmberObject.create({ id: 'user_a' }), + }; + const url = null; + const relationship = { + key: 'userBFeeds', + options: { + buildReference(firestore: Firestore, record: { id: string }) { + return collection(firestore, `users/${record.id}/feeds`); + }, + + filter(reference: CollectionReference) { + return query(reference, limit(1)); + }, + }, + type: 'post', + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.findHasMany(store, snapshot, url, relationship); + + // Assert + assert.equal(result[0].id, 'post_b'); + assert.equal(result[0].title, 'post_b'); + }); + }); + + module('function: query', function () { + test('should query for records', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const queryRef = { + filter(reference: CollectionReference) { + return query(reference, where('age', '>=', 15), limit(1)); + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.query(store, modelClass, queryRef); + + // Assert + assert.deepEqual(result, [ + { + id: 'user_a', + age: 15, + name: 'user_a', + username: 'user_a', + }, + ]); + }); + + test('should query for records in a custom collection', async function (assert) { + // Arrange + const store = {}; + const modelClass = { modelName: 'user' }; + const queryRef = { + buildReference(firestore: Firestore) { + return collection(firestore, 'admins'); + }, + + filter(reference: CollectionReference) { + return query(reference, where('since', '==', 2015)); + }, + }; + const adapter = this.owner.lookup('adapter:cloud-firestore-modular'); + + // Act + const result = await adapter.query(store, modelClass, queryRef); + + // Assert + assert.deepEqual(result, [{ id: 'user_b', since: 2015 }]); + }); + }); +}); diff --git a/tests/unit/serializers/cloud-firestore-modular-test.ts b/tests/unit/serializers/cloud-firestore-modular-test.ts new file mode 100644 index 00000000..f3e07345 --- /dev/null +++ b/tests/unit/serializers/cloud-firestore-modular-test.ts @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | cloud-firestore modular', function (hooks) { + setupTest(hooks); + + module('extractRelationship()', function () { + test('should return object containing the type and id of a relationship', function (assert) { + // Arrange + const serializer = this.owner.lookup('serializer:cloud-firestore-modular'); + + // Act + const result = serializer.extractRelationship('user', { + path: 'users/user_a', + firestore: {}, + }); + + // Assert + assert.deepEqual(result, { id: 'user_a', type: 'user' }); + }); + + test('should return null when without any relationship hash', function (assert) { + // Arrange + const serializer = this.owner.lookup('serializer:cloud-firestore-modular'); + + // Act + const result = serializer.extractRelationship('user', null); + + // Assert + assert.deepEqual(result, null); + }); + }); + + // NOTE: Other public methods are hard to test because they rely on private APIs from ember-data +});