From 618a5f167e7be30e79bd73a5cdb72cc643da9ae1 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 19 May 2022 15:57:10 -0400 Subject: [PATCH 01/16] moving DataSnapshot to common, removing lodash, and adding in initial v2 interface and logic --- src/common/providers/database.ts | 342 ++++++++++++++++++++++++++++ src/providers/database.ts | 294 +----------------------- src/v2/providers/database.ts | 369 +++++++++++++++++++++++++++++++ 3 files changed, 713 insertions(+), 292 deletions(-) create mode 100644 src/common/providers/database.ts create mode 100644 src/v2/providers/database.ts diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts new file mode 100644 index 000000000..2b9b8a862 --- /dev/null +++ b/src/common/providers/database.ts @@ -0,0 +1,342 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as firebase from 'firebase-admin'; +import { joinPath, /*normalizePath,*/ pathParts } from '../../utilities/path'; + +/** + * Interface representing a Firebase Realtime Database data snapshot. + */ +export class DataSnapshot { + public instance: string; + + /** @hidden */ + private _ref: firebase.database.Reference; + + /** @hidden */ + private _path: string; + + /** @hidden */ + private _data: any; + + /** @hidden */ + private _childPath: string; + + constructor( + data: any, + path?: string, // path will be undefined for the database root + private app?: firebase.app.App, + instance?: string + ) { + if (app?.options?.databaseURL?.startsWith('http:')) { + // In this case we're dealing with an emulator + this.instance = app.options.databaseURL; + } else if (instance) { + // SDK always supplies instance, but user's unit tests may not + this.instance = instance; + } else if (app) { + this.instance = app.options.databaseURL; + } else if (process.env.GCLOUD_PROJECT) { + this.instance = + 'https://' + process.env.GCLOUD_PROJECT + '.firebaseio.com'; + } + + this._path = path; + this._data = data; + } + + /** + * Returns a [`Reference`](/docs/reference/admin/node/admin.database.Reference) + * to the Database location where the triggering write occurred. Has + * full read and write access. + */ + get ref(): firebase.database.Reference { + if (!this.app) { + // may be unpopulated in user's unit tests + throw new Error( + 'Please supply a Firebase app in the constructor for DataSnapshot' + + ' in order to use the .ref method.' + ); + } + if (!this._ref) { + this._ref = this.app.database(this.instance).ref(this._fullPath()); + } + return this._ref; + } + + /** + * The key (last part of the path) of the location of this `DataSnapshot`. + * + * The last token in a Database location is considered its key. For example, + * "ada" is the key for the `/users/ada/` node. Accessing the key on any + * `DataSnapshot` will return the key for the location that generated it. + * However, accessing the key on the root URL of a Database will return `null`. + */ + get key(): string { + // const last = _.last(pathParts(this._fullPath())); + const segments = pathParts(this._fullPath()); + const last = segments[segments.length - 1]; + return !last || last === '' ? null : last; + } + + /** + * Extracts a JavaScript value from a `DataSnapshot`. + * + * Depending on the data in a `DataSnapshot`, the `val()` method may return a + * scalar type (string, number, or boolean), an array, or an object. It may also + * return `null`, indicating that the `DataSnapshot` is empty (contains no + * data). + * + * @return The DataSnapshot's contents as a JavaScript value (Object, + * Array, string, number, boolean, or `null`). + */ + val(): any { + const parts = pathParts(this._childPath); + let source = this._data; + if (parts.length) { + for (const part of parts) { + source = source[part]; + } + } + const node = { ...source }; + // const node = _.cloneDeep( + // parts.length ? _.get(source, parts, null) : source + // ); + + return this._checkAndConvertToArray(node); + } + + /** + * Exports the entire contents of the `DataSnapshot` as a JavaScript object. + * + * The `exportVal()` method is similar to `val()`, except priority information + * is included (if available), making it suitable for backing up your data. + * + * @return The contents of the `DataSnapshot` as a JavaScript value + * (Object, Array, string, number, boolean, or `null`). + */ + exportVal(): any { + return this.val(); + } + + /** + * Gets the priority value of the data in this `DataSnapshot`. + * + * As an alternative to using priority, applications can order collections by + * ordinary properties. See [Sorting and filtering + * data](/docs/database/web/lists-of-data#sorting_and_filtering_data). + * + * @return The priority value of the data. + */ + getPriority(): string | number | null { + return 0; + } + + /** + * Returns `true` if this `DataSnapshot` contains any data. It is slightly more + * efficient than using `snapshot.val() !== null`. + * + * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. + */ + exists(): boolean { + // return !_.isNull(this.val()); + return typeof this.val() === 'undefined' || this.val() === null + ? false + : true; + } + + /** + * Gets a `DataSnapshot` for the location at the specified relative path. + * + * The relative path can either be a simple child name (for example, "ada") or + * a deeper slash-separated path (for example, "ada/name/first"). + * + * @param path A relative path from this location to the desired child + * location. + * @return The specified child location. + */ + child(childPath: string): DataSnapshot { + if (!childPath) { + return this; + } + return this._dup(childPath); + } + + /** + * Enumerates the `DataSnapshot`s of the children items. + * + * Because of the way JavaScript objects work, the ordering of data in the + * JavaScript object returned by `val()` is not guaranteed to match the ordering + * on the server nor the ordering of `child_added` events. That is where + * `forEach()` comes in handy. It guarantees the children of a `DataSnapshot` + * will be iterated in their query order. + * + * If no explicit `orderBy*()` method is used, results are returned + * ordered by key (unless priorities are used, in which case, results are + * returned by priority). + * + * @param action A function that will be called for each child `DataSnapshot`. + * The callback can return `true` to cancel further enumeration. + * + * @return `true` if enumeration was canceled due to your callback + * returning `true`. + */ + forEach(action: (a: DataSnapshot) => boolean | void): boolean { + const val = this.val(); + // if (_.isPlainObject(val)) { + if (typeof val === 'object' && typeof val !== 'function') { + for (const key of Object.keys(val)) { + if (action(this.child(key)) === true) { + return true; + } + } + // return _.some( + // val, + // (value, key: string) => action(this.child(key)) === true + // ); + } + return false; + } + + /** + * Returns `true` if the specified child path has (non-`null`) data. + * + * @param path A relative path to the location of a potential child. + * @return `true` if data exists at the specified child path; otherwise, + * `false`. + */ + hasChild(childPath: string): boolean { + return this.child(childPath).exists(); + } + + /** + * Returns whether or not the `DataSnapshot` has any non-`null` child + * properties. + * + * You can use `hasChildren()` to determine if a `DataSnapshot` has any + * children. If it does, you can enumerate them using `forEach()`. If it + * doesn't, then either this snapshot contains a primitive value (which can be + * retrieved with `val()`) or it is empty (in which case, `val()` will return + * `null`). + * + * @return `true` if this snapshot has any children; else `false`. + */ + hasChildren(): boolean { + const val = this.val(); + // return _.isPlainObject(val) && _.keys(val).length > 0; + return ( + typeof val === 'object' && + typeof val !== 'function' && + Object.keys(val).length > 0 + ); + } + + /** + * Returns the number of child properties of this `DataSnapshot`. + * + * @return Number of child properties of this `DataSnapshot`. + */ + numChildren(): number { + const val = this.val(); + // return _.isPlainObject(val) ? Object.keys(val).length : 0; + return typeof val === 'object' && typeof val !== 'function' + ? Object.keys(val).length + : 0; + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @return A JSON-serializable representation of this object. + */ + toJSON(): Object { + return this.val(); + } + + /** Recursive function to check if keys are numeric & convert node object to array if they are + * + * @hidden + */ + private _checkAndConvertToArray(node: any): any { + if (node === null || typeof node === 'undefined') { + return null; + } + if (typeof node !== 'object') { + return node; + } + const obj: any = {}; + let numKeys = 0; + let maxKey = 0; + let allIntegerKeys = true; + for (const key in node) { + if (!node.hasOwnProperty(key)) { + continue; + } + const childNode = node[key]; + obj[key] = this._checkAndConvertToArray(childNode); + numKeys++; + const integerRegExp = /^(0|[1-9]\d*)$/; + if (allIntegerKeys && integerRegExp.test(key)) { + maxKey = Math.max(maxKey, Number(key)); + } else { + allIntegerKeys = false; + } + } + + if (allIntegerKeys && maxKey < 2 * numKeys) { + // convert to array. + const array: any = []; + for (const key of Object.keys(obj)) { + array[key] = obj[key]; + } + // _.forOwn(obj, (val, key) => { + // array[key] = val; + // }); + + return array; + } + return obj; + } + + /** @hidden */ + private _dup(childPath?: string): DataSnapshot { + const dup = new DataSnapshot( + this._data, + undefined, + this.app, + this.instance + ); + [dup._path, dup._childPath] = [this._path, this._childPath]; + + if (childPath) { + dup._childPath = joinPath(dup._childPath, childPath); + } + + return dup; + } + + /** @hidden */ + private _fullPath(): string { + const out = (this._path || '') + '/' + (this._childPath || ''); + return out; + } +} diff --git a/src/providers/database.ts b/src/providers/database.ts index e5f4d8ed3..8faca23ed 100644 --- a/src/providers/database.ts +++ b/src/providers/database.ts @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as firebase from 'firebase-admin'; -import * as _ from 'lodash'; import { apps } from '../apps'; import { Change, @@ -30,9 +28,10 @@ import { EventContext, makeCloudFunction, } from '../cloud-functions'; +import { DataSnapshot } from '../common/providers/database'; import { firebaseConfig } from '../config'; import { DeploymentOptions } from '../function-configuration'; -import { joinPath, normalizePath, pathParts } from '../utilities/path'; +import { normalizePath } from '../utilities/path'; import { applyChange } from '../utils'; /** @hidden */ @@ -345,292 +344,3 @@ export function extractInstanceAndPath( return [dbInstance, path]; } } - -/** - * Interface representing a Firebase Realtime Database data snapshot. - */ -export class DataSnapshot { - public instance: string; - - /** @hidden */ - private _ref: firebase.database.Reference; - - /** @hidden */ - private _path: string; - - /** @hidden */ - private _data: any; - - /** @hidden */ - private _childPath: string; - - constructor( - data: any, - path?: string, // path will be undefined for the database root - private app?: firebase.app.App, - instance?: string - ) { - if (app?.options?.databaseURL?.startsWith('http:')) { - // In this case we're dealing with an emulator - this.instance = app.options.databaseURL; - } else if (instance) { - // SDK always supplies instance, but user's unit tests may not - this.instance = instance; - } else if (app) { - this.instance = app.options.databaseURL; - } else if (process.env.GCLOUD_PROJECT) { - this.instance = - 'https://' + process.env.GCLOUD_PROJECT + '.firebaseio.com'; - } - - this._path = path; - this._data = data; - } - - /** - * Returns a [`Reference`](/docs/reference/admin/node/admin.database.Reference) - * to the Database location where the triggering write occurred. Has - * full read and write access. - */ - get ref(): firebase.database.Reference { - if (!this.app) { - // may be unpopulated in user's unit tests - throw new Error( - 'Please supply a Firebase app in the constructor for DataSnapshot' + - ' in order to use the .ref method.' - ); - } - if (!this._ref) { - this._ref = this.app.database(this.instance).ref(this._fullPath()); - } - return this._ref; - } - - /** - * The key (last part of the path) of the location of this `DataSnapshot`. - * - * The last token in a Database location is considered its key. For example, - * "ada" is the key for the `/users/ada/` node. Accessing the key on any - * `DataSnapshot` will return the key for the location that generated it. - * However, accessing the key on the root URL of a Database will return `null`. - */ - get key(): string { - const last = _.last(pathParts(this._fullPath())); - return !last || last === '' ? null : last; - } - - /** - * Extracts a JavaScript value from a `DataSnapshot`. - * - * Depending on the data in a `DataSnapshot`, the `val()` method may return a - * scalar type (string, number, or boolean), an array, or an object. It may also - * return `null`, indicating that the `DataSnapshot` is empty (contains no - * data). - * - * @return The DataSnapshot's contents as a JavaScript value (Object, - * Array, string, number, boolean, or `null`). - */ - val(): any { - const parts = pathParts(this._childPath); - const source = this._data; - const node = _.cloneDeep( - parts.length ? _.get(source, parts, null) : source - ); - return this._checkAndConvertToArray(node); - } - - /** - * Exports the entire contents of the `DataSnapshot` as a JavaScript object. - * - * The `exportVal()` method is similar to `val()`, except priority information - * is included (if available), making it suitable for backing up your data. - * - * @return The contents of the `DataSnapshot` as a JavaScript value - * (Object, Array, string, number, boolean, or `null`). - */ - exportVal(): any { - return this.val(); - } - - /** - * Gets the priority value of the data in this `DataSnapshot`. - * - * As an alternative to using priority, applications can order collections by - * ordinary properties. See [Sorting and filtering - * data](/docs/database/web/lists-of-data#sorting_and_filtering_data). - * - * @return The priority value of the data. - */ - getPriority(): string | number | null { - return 0; - } - - /** - * Returns `true` if this `DataSnapshot` contains any data. It is slightly more - * efficient than using `snapshot.val() !== null`. - * - * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. - */ - exists(): boolean { - return !_.isNull(this.val()); - } - - /** - * Gets a `DataSnapshot` for the location at the specified relative path. - * - * The relative path can either be a simple child name (for example, "ada") or - * a deeper slash-separated path (for example, "ada/name/first"). - * - * @param path A relative path from this location to the desired child - * location. - * @return The specified child location. - */ - child(childPath: string): DataSnapshot { - if (!childPath) { - return this; - } - return this._dup(childPath); - } - - /** - * Enumerates the `DataSnapshot`s of the children items. - * - * Because of the way JavaScript objects work, the ordering of data in the - * JavaScript object returned by `val()` is not guaranteed to match the ordering - * on the server nor the ordering of `child_added` events. That is where - * `forEach()` comes in handy. It guarantees the children of a `DataSnapshot` - * will be iterated in their query order. - * - * If no explicit `orderBy*()` method is used, results are returned - * ordered by key (unless priorities are used, in which case, results are - * returned by priority). - * - * @param action A function that will be called for each child `DataSnapshot`. - * The callback can return `true` to cancel further enumeration. - * - * @return `true` if enumeration was canceled due to your callback - * returning `true`. - */ - forEach(action: (a: DataSnapshot) => boolean | void): boolean { - const val = this.val(); - if (_.isPlainObject(val)) { - return _.some( - val, - (value, key: string) => action(this.child(key)) === true - ); - } - return false; - } - - /** - * Returns `true` if the specified child path has (non-`null`) data. - * - * @param path A relative path to the location of a potential child. - * @return `true` if data exists at the specified child path; otherwise, - * `false`. - */ - hasChild(childPath: string): boolean { - return this.child(childPath).exists(); - } - - /** - * Returns whether or not the `DataSnapshot` has any non-`null` child - * properties. - * - * You can use `hasChildren()` to determine if a `DataSnapshot` has any - * children. If it does, you can enumerate them using `forEach()`. If it - * doesn't, then either this snapshot contains a primitive value (which can be - * retrieved with `val()`) or it is empty (in which case, `val()` will return - * `null`). - * - * @return `true` if this snapshot has any children; else `false`. - */ - hasChildren(): boolean { - const val = this.val(); - return _.isPlainObject(val) && _.keys(val).length > 0; - } - - /** - * Returns the number of child properties of this `DataSnapshot`. - * - * @return Number of child properties of this `DataSnapshot`. - */ - numChildren(): number { - const val = this.val(); - return _.isPlainObject(val) ? Object.keys(val).length : 0; - } - - /** - * Returns a JSON-serializable representation of this object. - * - * @return A JSON-serializable representation of this object. - */ - toJSON(): Object { - return this.val(); - } - - /** Recursive function to check if keys are numeric & convert node object to array if they are - * - * @hidden - */ - private _checkAndConvertToArray(node: any): any { - if (node === null || typeof node === 'undefined') { - return null; - } - if (typeof node !== 'object') { - return node; - } - const obj: any = {}; - let numKeys = 0; - let maxKey = 0; - let allIntegerKeys = true; - for (const key in node) { - if (!node.hasOwnProperty(key)) { - continue; - } - const childNode = node[key]; - obj[key] = this._checkAndConvertToArray(childNode); - numKeys++; - const integerRegExp = /^(0|[1-9]\d*)$/; - if (allIntegerKeys && integerRegExp.test(key)) { - maxKey = Math.max(maxKey, Number(key)); - } else { - allIntegerKeys = false; - } - } - - if (allIntegerKeys && maxKey < 2 * numKeys) { - // convert to array. - const array: any = []; - _.forOwn(obj, (val, key) => { - array[key] = val; - }); - - return array; - } - return obj; - } - - /** @hidden */ - private _dup(childPath?: string): DataSnapshot { - const dup = new DataSnapshot( - this._data, - undefined, - this.app, - this.instance - ); - [dup._path, dup._childPath] = [this._path, this._childPath]; - - if (childPath) { - dup._childPath = joinPath(dup._childPath, childPath); - } - - return dup; - } - - /** @hidden */ - private _fullPath(): string { - const out = (this._path || '') + '/' + (this._childPath || ''); - return out; - } -} diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts new file mode 100644 index 000000000..985f46d2b --- /dev/null +++ b/src/v2/providers/database.ts @@ -0,0 +1,369 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { apps } from '../../apps'; +import { Change } from '../../cloud-functions'; +import { DataSnapshot } from '../../common/providers/database'; +import { ManifestEndpoint } from '../../runtime/manifest'; +import { CloudEvent, CloudFunction } from '../core'; +import * as options from '../options'; + +export { DataSnapshot }; + +const WILDCARD_REGEX = new RegExp('{[^/{}]*}', 'g'); + +/** @internal */ +export const writtenEventType = 'google.firebase.database.ref.v1.written'; + +/** @internal */ +export const createdEventType = 'google.firebase.database.ref.v1.created'; + +/** @internal */ +export const updatedEventType = 'google.firebase.database.ref.v1.updated'; + +/** @internal */ +export const deletedEventType = 'google.firebase.database.ref.v1.deleted'; + +/** @internal */ +export interface RawCloudEventData { + // properties from CloudEvent + ['@type']: 'type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData'; + data: any; + delta: any; +} + +/** @internal */ +export interface RawCloudEvent extends CloudEvent { + /** The domain of the database instance */ + firebasedatabasehost: string; + /** The instance ID portion of the fully qualified resource name */ + instance: string; + /** The database reference path */ + ref: string; + /** The location of the database */ + location: string; +} + +export interface DatabaseEvent extends CloudEvent { + /** The domain of the database instance */ + firebasedatabasehost: string; + /** The instance ID portion of the fully qualified resource name */ + instance: string; + /** The database reference path */ + ref: string; + /** The location of the database */ + location: string; + /** + * An object containing the values of the path patterns. + * Only named capture groups will be populated - {key}, {key=*}, {key=**} + */ + params: Record; +} + +export interface ReferenceOptions extends options.EventHandlerOptions { + /** + * Specify the handler to trigger on a database reference(s). + * This value can either be a single reference or a pattern. + * Examples~ '/foo/bar', '/foo/*' + */ + ref: string; + /** + * Specify the handler to trigger on a database instance(s). + * If present, this value can either be a single instance or a pattern. + * Examples~ 'my-instance-1', 'my-instance-*', '*' + */ + instance?: string; +} + +export function onRefWritten( + reference: string, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; +export function onRefWritten( + opts: ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; +export function onRefWritten( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + return onOperation( + writtenEventType, + referenceOrOpts, + handler, + true + ) as CloudFunction>>; +} + +export function onRefCreated( + reference: string, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; +export function onRefCreated( + opts: ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; +export function onRefCreated( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + return onOperation( + createdEventType, + referenceOrOpts, + handler, + false + ) as CloudFunction>; +} + +export function onRefUpdated( + reference: string, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; +export function onRefUpdated( + opts: ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; +export function onRefUpdated( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + return onOperation( + updatedEventType, + referenceOrOpts, + handler, + true + ) as CloudFunction>>; +} + +export function onRefDeleted( + reference: string, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; +export function onRefDeleted( + opts: ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; +export function onRefDeleted( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + return onOperation( + deletedEventType, + referenceOrOpts, + handler, + false + ) as CloudFunction>; +} + +/* + +{ + id: "some-random-unique-id" + source: "//$RTDB_API/projects/_/locations/us-central1/instances/ns-default" + spec_version: "1.0" + type: "google.firebase.database.ref.v1.deleted" + attributes: { + "subject": { "ceString": "refs/foo/path" }, + "datacontenttype": { "ceString": "application/json" }, + "time": { "ceTimestamp": "1970-01-01T00:00:10Z" }, + "location": { "ceString": "us-central1" }, + "firebasedatabasehost": { "ceString": "firebaseio.com" }, + "instance": { "ceString": "my-db" }, + "ref": { "ceString": "foo/path" }, + } + # see example test_data + text_data: "{ + \"@type\": \"type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData\", + \"data\": ... + \"delta\": ... + }” +} + +*/ + +/** @internal */ +function makeInstance(event: RawCloudEvent): string { + return `https://${event.instance}.${event.firebasedatabasehost}`; +} + +function trimParam(param: string) { + const paramNoBraces = param.slice(1, -1); + if (paramNoBraces.includes('=')) { + return paramNoBraces.slice(0, paramNoBraces.indexOf('=') - 1); + } + return paramNoBraces; +} + +/** @internal */ +export function makeParams( + event: RawCloudEvent, + path: string, + instance: string | undefined +): Record { + const params: Record = {}; + + const pathWildcards = path.match(WILDCARD_REGEX); + if (pathWildcards) { + const pathParts = path.split('/'); + const eventPathParts = event.ref.split('/'); + for (const wildcard of pathWildcards) { + const trimmedWildcard = trimParam(wildcard); + const position = pathParts.indexOf(wildcard); + params[trimmedWildcard] = eventPathParts[position]; + } + } + + const instanceWildcards = instance.match(WILDCARD_REGEX); + /** my-{key}-db is not allowed or {key}-some-{db} */ + if ( + instanceWildcards && + instanceWildcards.length === 1 && + instance.charAt(0) === '{' && + instance.charAt(instance.length - 1) === '}' + ) { + const trimmedWildcard = trimParam(instanceWildcards[0]); + params[trimmedWildcard] = event.instance; + } + + return params; +} + +function makeDatabaseEvent( + event: RawCloudEvent, + params: Record +): DatabaseEvent { + const snapshot = new DataSnapshot( + event.data.data, + event.ref, + apps().admin, + makeInstance(event) + ); + const databaseEvent: DatabaseEvent = { + ...event, + data: snapshot, + params, + }; + return databaseEvent; +} + +function makeChangedDatabaseEvent( + event: RawCloudEvent, + params: Record +) { + const before = new DataSnapshot( + event.data.data, + event.ref, + apps().admin, + makeInstance(event) + ); + const after = new DataSnapshot( + event.data.delta, + event.ref, + apps().admin, + makeInstance(event) + ); + const databaseEvent: DatabaseEvent> = { + ...event, + data: { + before, + after, + }, + params, + }; + return databaseEvent; +} + +function onOperation( + eventType: string, + referenceOrOpts: string | ReferenceOptions, + handler: ( + event: DatabaseEvent | DatabaseEvent> + ) => any | Promise, + changed: boolean +): + | CloudFunction> + | CloudFunction>> { + let path: string, instance: string, opts: options.EventHandlerOptions; + if (typeof referenceOrOpts === 'string') { + path = referenceOrOpts; + instance = undefined; + opts = {}; + } else { + path = referenceOrOpts.ref; + instance = referenceOrOpts.instance; + opts = { ...referenceOrOpts }; + delete (opts as any).ref; + delete (opts as any).instance; + } + + // normalize path & instance + + // wrap the handler + const func = (raw: CloudEvent) => { + const event = raw as RawCloudEvent; + const params = makeParams(event, path, instance); + const databaseEvent = changed + ? makeChangedDatabaseEvent(event, params) + : makeDatabaseEvent(event, params); + return handler(databaseEvent); + }; + + func.run = handler; + + const baseOpts = options.optionsToEndpoint(options.getGlobalOptions()); + const specificOpts = options.optionsToEndpoint(opts); + + const eventFilters: Record = {}; + const eventFilterPathPatterns: Record = {}; + + if (path.match(WILDCARD_REGEX) || path.includes('*')) { + eventFilterPathPatterns.ref = path; + } else { + eventFilters.ref = path; + } + + if (instance.match(WILDCARD_REGEX) || instance.includes('*')) { + eventFilterPathPatterns.instance = instance; + } else { + eventFilters.instance = instance; + } + + const endpoint: ManifestEndpoint = { + platform: 'gcfv2', + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + eventTrigger: { + eventType, + eventFilters, + eventFilterPathPatterns, + retry: false, + }, + }; + + func.__endpoint = endpoint; + + return func; +} From ca548fa63abef1ad53d1c01ad8c5ab6bd99c80b7 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 19 May 2022 16:49:43 -0400 Subject: [PATCH 02/16] fixing tests --- src/common/providers/database.ts | 2 +- src/providers/database.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index 2b9b8a862..7f9232d44 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -117,7 +117,7 @@ export class DataSnapshot { source = source[part]; } } - const node = { ...source }; + const node = typeof source !== 'undefined' ? source : null; // const node = _.cloneDeep( // parts.length ? _.get(source, parts, null) : source // ); diff --git a/src/providers/database.ts b/src/providers/database.ts index 8faca23ed..5b7dc59f5 100644 --- a/src/providers/database.ts +++ b/src/providers/database.ts @@ -34,6 +34,8 @@ import { DeploymentOptions } from '../function-configuration'; import { normalizePath } from '../utilities/path'; import { applyChange } from '../utils'; +export { DataSnapshot }; + /** @hidden */ export const provider = 'google.firebase.database'; /** @hidden */ From 8a48ad6791150848320056cd6c5ed4abf1b4f657 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Fri, 20 May 2022 12:22:18 -0400 Subject: [PATCH 03/16] normalize path & remove commented structure --- src/v2/providers/database.ts | 78 +++++++++++++----------------------- 1 file changed, 27 insertions(+), 51 deletions(-) diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 985f46d2b..2447b8f99 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -24,6 +24,7 @@ import { apps } from '../../apps'; import { Change } from '../../cloud-functions'; import { DataSnapshot } from '../../common/providers/database'; import { ManifestEndpoint } from '../../runtime/manifest'; +import { normalizePath } from '../../utilities/path'; import { CloudEvent, CloudFunction } from '../core'; import * as options from '../options'; @@ -44,15 +45,14 @@ export const updatedEventType = 'google.firebase.database.ref.v1.updated'; export const deletedEventType = 'google.firebase.database.ref.v1.deleted'; /** @internal */ -export interface RawCloudEventData { - // properties from CloudEvent +export interface RawRTDBCloudEventData { ['@type']: 'type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData'; data: any; delta: any; } /** @internal */ -export interface RawCloudEvent extends CloudEvent { +export interface RawRTDBCloudEvent extends CloudEvent { /** The domain of the database instance */ firebasedatabasehost: string; /** The instance ID portion of the fully qualified resource name */ @@ -83,13 +83,13 @@ export interface ReferenceOptions extends options.EventHandlerOptions { /** * Specify the handler to trigger on a database reference(s). * This value can either be a single reference or a pattern. - * Examples~ '/foo/bar', '/foo/*' + * Examples~ '/foo/bar', '/foo/{bar} ' */ ref: string; /** * Specify the handler to trigger on a database instance(s). * If present, this value can either be a single instance or a pattern. - * Examples~ 'my-instance-1', 'my-instance-*', '*' + * Examples~ 'my-instance-1', '{instance}', '*' */ instance?: string; } @@ -174,38 +174,8 @@ export function onRefDeleted( ) as CloudFunction>; } -/* - -{ - id: "some-random-unique-id" - source: "//$RTDB_API/projects/_/locations/us-central1/instances/ns-default" - spec_version: "1.0" - type: "google.firebase.database.ref.v1.deleted" - attributes: { - "subject": { "ceString": "refs/foo/path" }, - "datacontenttype": { "ceString": "application/json" }, - "time": { "ceTimestamp": "1970-01-01T00:00:10Z" }, - "location": { "ceString": "us-central1" }, - "firebasedatabasehost": { "ceString": "firebaseio.com" }, - "instance": { "ceString": "my-db" }, - "ref": { "ceString": "foo/path" }, - } - # see example test_data - text_data: "{ - \"@type\": \"type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData\", - \"data\": ... - \"delta\": ... - }” -} - -*/ - /** @internal */ -function makeInstance(event: RawCloudEvent): string { - return `https://${event.instance}.${event.firebasedatabasehost}`; -} - -function trimParam(param: string) { +export function trimParam(param: string) { const paramNoBraces = param.slice(1, -1); if (paramNoBraces.includes('=')) { return paramNoBraces.slice(0, paramNoBraces.indexOf('=') - 1); @@ -215,7 +185,7 @@ function trimParam(param: string) { /** @internal */ export function makeParams( - event: RawCloudEvent, + event: RawRTDBCloudEvent, path: string, instance: string | undefined ): Record { @@ -232,8 +202,12 @@ export function makeParams( } } + if (!instance) { + return params; + } + const instanceWildcards = instance.match(WILDCARD_REGEX); - /** my-{key}-db is not allowed or {key}-some-{db} */ + /** my-{key}-db or {key}-some-{db} is not allowed */ if ( instanceWildcards && instanceWildcards.length === 1 && @@ -248,14 +222,15 @@ export function makeParams( } function makeDatabaseEvent( - event: RawCloudEvent, + event: RawRTDBCloudEvent, + instance: string, params: Record ): DatabaseEvent { const snapshot = new DataSnapshot( event.data.data, event.ref, apps().admin, - makeInstance(event) + instance ); const databaseEvent: DatabaseEvent = { ...event, @@ -266,20 +241,21 @@ function makeDatabaseEvent( } function makeChangedDatabaseEvent( - event: RawCloudEvent, + event: RawRTDBCloudEvent, + instance: string, params: Record ) { const before = new DataSnapshot( event.data.data, event.ref, apps().admin, - makeInstance(event) + instance ); const after = new DataSnapshot( event.data.delta, event.ref, apps().admin, - makeInstance(event) + instance ); const databaseEvent: DatabaseEvent> = { ...event, @@ -292,7 +268,8 @@ function makeChangedDatabaseEvent( return databaseEvent; } -function onOperation( +/** @internal */ +export function onOperation( eventType: string, referenceOrOpts: string | ReferenceOptions, handler: ( @@ -304,26 +281,25 @@ function onOperation( | CloudFunction>> { let path: string, instance: string, opts: options.EventHandlerOptions; if (typeof referenceOrOpts === 'string') { - path = referenceOrOpts; + path = normalizePath(referenceOrOpts); instance = undefined; opts = {}; } else { - path = referenceOrOpts.ref; + path = normalizePath(referenceOrOpts.ref); instance = referenceOrOpts.instance; opts = { ...referenceOrOpts }; delete (opts as any).ref; delete (opts as any).instance; } - // normalize path & instance - // wrap the handler const func = (raw: CloudEvent) => { - const event = raw as RawCloudEvent; + const event = raw as RawRTDBCloudEvent; + const instance = `https://${event.instance}.${event.firebasedatabasehost}`; const params = makeParams(event, path, instance); const databaseEvent = changed - ? makeChangedDatabaseEvent(event, params) - : makeDatabaseEvent(event, params); + ? makeChangedDatabaseEvent(event, instance, params) + : makeDatabaseEvent(event, instance, params); return handler(databaseEvent); }; From f2d3ee0ebed805fe32f83708896388bb0bdc0d60 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Mon, 23 May 2022 11:04:32 -0400 Subject: [PATCH 04/16] changing default rtdb url --- src/common/providers/database.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index 7f9232d44..a8068c782 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -57,7 +57,9 @@ export class DataSnapshot { this.instance = app.options.databaseURL; } else if (process.env.GCLOUD_PROJECT) { this.instance = - 'https://' + process.env.GCLOUD_PROJECT + '.firebaseio.com'; + 'https://' + + process.env.GCLOUD_PROJECT + + '-default-rtdb.firebaseio.com'; } this._path = path; From 262ebddbf90b0e5ad324804e092da72f78b2c806 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Tue, 24 May 2022 14:14:07 -0400 Subject: [PATCH 05/16] defualt to * instances and export everything correctly --- package.json | 6 +++++- src/v2/index.ts | 3 ++- src/v2/providers/database.ts | 16 ++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 026c79db3..f0e0b61d8 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "./v2/alerts/billing": "./lib/v2/providers/alerts/billing.js", "./v2/alerts/crashlytics": "./lib/v2/providers/alerts/crashlytics.js", "./v2/eventarc": "./lib/v2/providers/eventarc.js", - "./v2/identity": "./lib/v2/providers/identity.js" + "./v2/identity": "./lib/v2/providers/identity.js", + "./v2/database": "./lib/v2/providers/database.js" }, "typesVersions": { "*": { @@ -126,6 +127,9 @@ "v2/base": [ "lib/v2/base" ], + "v2/database": [ + "lib/v2/database" + ], "v2/eventarc": [ "lib/v2/providers/eventarc" ], diff --git a/src/v2/index.ts b/src/v2/index.ts index d1cf4836a..130b91e40 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -30,6 +30,7 @@ import * as logger from '../logger'; import * as alerts from './providers/alerts'; +import * as database from './providers/database'; import * as eventarc from './providers/eventarc'; import * as https from './providers/https'; import * as identity from './providers/identity'; @@ -37,7 +38,7 @@ import * as pubsub from './providers/pubsub'; import * as storage from './providers/storage'; import * as tasks from './providers/tasks'; -export { alerts, storage, https, identity, pubsub, logger, tasks, eventarc }; +export { alerts, database, storage, https, identity, pubsub, logger, tasks, eventarc }; export { setGlobalOptions, diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 2447b8f99..1caa4964b 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -187,7 +187,7 @@ export function trimParam(param: string) { export function makeParams( event: RawRTDBCloudEvent, path: string, - instance: string | undefined + instance: string ): Record { const params: Record = {}; @@ -202,10 +202,6 @@ export function makeParams( } } - if (!instance) { - return params; - } - const instanceWildcards = instance.match(WILDCARD_REGEX); /** my-{key}-db or {key}-some-{db} is not allowed */ if ( @@ -282,11 +278,11 @@ export function onOperation( let path: string, instance: string, opts: options.EventHandlerOptions; if (typeof referenceOrOpts === 'string') { path = normalizePath(referenceOrOpts); - instance = undefined; + instance = "*"; opts = {}; } else { path = normalizePath(referenceOrOpts.ref); - instance = referenceOrOpts.instance; + instance = referenceOrOpts.instance || "*"; opts = { ...referenceOrOpts }; delete (opts as any).ref; delete (opts as any).instance; @@ -295,11 +291,11 @@ export function onOperation( // wrap the handler const func = (raw: CloudEvent) => { const event = raw as RawRTDBCloudEvent; - const instance = `https://${event.instance}.${event.firebasedatabasehost}`; + const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; const params = makeParams(event, path, instance); const databaseEvent = changed - ? makeChangedDatabaseEvent(event, instance, params) - : makeDatabaseEvent(event, instance, params); + ? makeChangedDatabaseEvent(event, instanceUrl, params) + : makeDatabaseEvent(event, instanceUrl, params); return handler(databaseEvent); }; From dcdc434c6c6af6cc0953c13c9d99b5eaef0bb1ae Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Tue, 24 May 2022 14:16:28 -0400 Subject: [PATCH 06/16] linter --- src/v2/index.ts | 12 +++++++++++- src/v2/providers/database.ts | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index 130b91e40..d2161ab71 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -38,7 +38,17 @@ import * as pubsub from './providers/pubsub'; import * as storage from './providers/storage'; import * as tasks from './providers/tasks'; -export { alerts, database, storage, https, identity, pubsub, logger, tasks, eventarc }; +export { + alerts, + database, + storage, + https, + identity, + pubsub, + logger, + tasks, + eventarc, +}; export { setGlobalOptions, diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 1caa4964b..5b4faba23 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -278,11 +278,11 @@ export function onOperation( let path: string, instance: string, opts: options.EventHandlerOptions; if (typeof referenceOrOpts === 'string') { path = normalizePath(referenceOrOpts); - instance = "*"; + instance = '*'; opts = {}; } else { path = normalizePath(referenceOrOpts.ref); - instance = referenceOrOpts.instance || "*"; + instance = referenceOrOpts.instance || '*'; opts = { ...referenceOrOpts }; delete (opts as any).ref; delete (opts as any).instance; From 973cb3d82022b9445f05fb62d6e9aaa6c9dbce35 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 26 May 2022 14:33:41 -0400 Subject: [PATCH 07/16] fixed parsing for multi segments and adding in tests --- spec/v2/providers/database.spec.ts | 606 +++++++++++++++++++++++++++++ src/v2/providers/database.ts | 110 ++++-- 2 files changed, 680 insertions(+), 36 deletions(-) create mode 100644 spec/v2/providers/database.spec.ts diff --git a/spec/v2/providers/database.spec.ts b/spec/v2/providers/database.spec.ts new file mode 100644 index 000000000..74397b9cd --- /dev/null +++ b/spec/v2/providers/database.spec.ts @@ -0,0 +1,606 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { expect } from 'chai'; +import * as database from '../../../src/v2/providers/database'; + +const RAW_RTDB_EVENT: database.RawRTDBCloudEvent = { + data: { + ['@type']: + 'type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData', + data: {}, + delta: {}, + }, + firebasedatabasehost: 'firebaseio.com', + instance: 'my-instance', + ref: 'foo/bar', + location: 'us-central1', + id: 'id', + source: 'source', + specversion: '1.0', + time: 'time', + type: 'type', +}; + +describe('matchParams', () => { + it('should parse without multi segment', () => { + const params = {}; + + database.matchParams( + '{a}/something/else/{b}/end/{c}'.split('/'), + ['{a}', '{b}', '{c}'], + 'match_a/something/else/match_b/end/match_c'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'match_a', + b: 'match_b', + c: 'match_c', + }); + }); + + it('should parse multi segment with params after', () => { + const params = {}; + + database.matchParams( + 'something/**/else/{a}/hello/{b}/world'.split('/'), + ['{a}', '{b}'], + 'something/is/a/thing/else/nothing/hello/user/world'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment param with params after', () => { + const params = {}; + + database.matchParams( + 'something/{path=**}/else/{a}/hello/{b}/world'.split('/'), + ['{path=**}', '{a}', '{b}'], + 'something/is/a/thing/else/nothing/hello/user/world'.split('/'), + params + ); + + expect(params).to.deep.equal({ + path: 'is/a/thing', + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment with params before', () => { + const params = {}; + + database.matchParams( + '{a}/something/{b}/**/end'.split('/'), + ['{a}', '{b}'], + 'match_a/something/match_b/thing/else/nothing/hello/user/end'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const params = {}; + + database.matchParams( + '{a}/something/{b}/{path=**}/end'.split('/'), + ['{a}', '{b}', '{path=**}'], + 'match_a/something/match_b/thing/else/nothing/hello/user/end'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + it('should parse multi segment with params before and after', () => { + const params = {}; + + database.matchParams( + '{a}/something/**/{b}/end'.split('/'), + ['{a}', '{b}'], + 'match_a/something/thing/else/nothing/hello/user/match_b/end'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const params = {}; + + database.matchParams( + '{a}/something/{path=**}/{b}/end'.split('/'), + ['{a}', '{path=**}', '{b}'], + 'match_a/something/thing/else/nothing/hello/user/match_b/end'.split('/'), + params + ); + + expect(params).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + // handle an instance param + it('should parse an instance', () => { + const params = {}; + + database.matchParams( + ['{a}-something-{b}-else-{c}'], + ['{a}', '{b}', '{c}'], + ['match_a-something-match_b-else-match_c'], + params + ); + + expect(params).to.deep.equal({}); + + database.matchParams(['{a}'], ['{a}'], ['match_a'], params); + + expect(params).to.deep.equal({ + a: 'match_a', + }); + }); +}); + +describe('makeParams', () => { + it('should make params with basic path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'match_a/something/else/nothing/end/match_b', + }; + + expect( + database.makeParams(event, '{a}/something/else/*/end/{b}', '*') + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params with multi segment path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams(event, 'something/**/else/{a}/hello/{b}/world', '*') + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params with multi segment path capture', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + 'something/{path=**}/else/{a}/hello/{b}/world', + '*' + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params for a full path and instance', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + 'something/{path=**}/else/{a}/hello/{b}/world', + '{inst}' + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + inst: 'my-instance', + }); + }); +}); + +describe('getOpts', () => { + it('should return opts when passed in a path', () => { + expect(database.getOpts('/foo/{bar}/')).to.deep.equal({ + path: 'foo/{bar}', + instance: '*', + opts: {}, + }); + }); + + it('should return opts when passed in an options object', () => { + expect( + database.getOpts({ + ref: '/foo/{bar}/', + instance: '{inst}', + region: 'us-central1', + }) + ).to.deep.equal({ + path: 'foo/{bar}', + instance: '{inst}', + opts: { + region: 'us-central1', + }, + }); + }); +}); + +describe('onOperation', () => { + it('should create a function for a written event', () => { + const func = database.onOperation( + database.writtenEventType, + '/foo/{bar}/', + (event) => 2, + true + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function for a created event', () => { + const func = database.onOperation( + database.createdEventType, + '/foo/{bar}/', + (event) => 2, + true + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function for a updated event', () => { + const func = database.onOperation( + database.updatedEventType, + '/foo/{bar}/', + (event) => 2, + true + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function for a deleted event', () => { + const func = database.onOperation( + database.deletedEventType, + '/foo/{bar}/', + (event) => 2, + true + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a complex function', () => { + const func = database.onOperation( + database.writtenEventType, + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2, + true + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); +}); + +describe('onRefWritten', () => { + it('should create a function with a reference', () => { + const func = database.onRefWritten('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefWritten( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); +}); + +describe('onRefCreated', () => { + it('should create a function with a reference', () => { + const func = database.onRefCreated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefCreated( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); +}); + +describe('onRefUpdated', () => { + it('should create a function with a reference', () => { + const func = database.onRefUpdated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefUpdated( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); +}); + +describe('onRefDeleted', () => { + it('should create a function with a reference', () => { + const func = database.onRefDeleted('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefDeleted( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); +}); diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 5b4faba23..f3611f33a 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -25,11 +25,13 @@ import { Change } from '../../cloud-functions'; import { DataSnapshot } from '../../common/providers/database'; import { ManifestEndpoint } from '../../runtime/manifest'; import { normalizePath } from '../../utilities/path'; +import { applyChange } from '../../utils'; import { CloudEvent, CloudFunction } from '../core'; import * as options from '../options'; export { DataSnapshot }; +/** @hidden */ const WILDCARD_REGEX = new RegExp('{[^/{}]*}', 'g'); /** @internal */ @@ -65,7 +67,7 @@ export interface RawRTDBCloudEvent extends CloudEvent { export interface DatabaseEvent extends CloudEvent { /** The domain of the database instance */ - firebasedatabasehost: string; + firebaseDatabaseHost: string; /** The instance ID portion of the fully qualified resource name */ instance: string; /** The database reference path */ @@ -175,14 +177,70 @@ export function onRefDeleted( } /** @internal */ -export function trimParam(param: string) { +export function getOpts(referenceOrOpts: string | ReferenceOptions) { + let path: string, instance: string, opts: options.EventHandlerOptions; + if (typeof referenceOrOpts === 'string') { + path = normalizePath(referenceOrOpts); + instance = '*'; + opts = {}; + } else { + path = normalizePath(referenceOrOpts.ref); + instance = referenceOrOpts.instance || '*'; + opts = { ...referenceOrOpts }; + delete (opts as any).ref; + delete (opts as any).instance; + } + + return { + path, + instance, + opts, + }; +} + +/** @hidden */ +function trimParam(param: string) { const paramNoBraces = param.slice(1, -1); if (paramNoBraces.includes('=')) { - return paramNoBraces.slice(0, paramNoBraces.indexOf('=') - 1); + return paramNoBraces.slice(0, paramNoBraces.indexOf('=')); } return paramNoBraces; } +/** @internal */ +export function matchParams( + pathSegments: string[], + wildcards: RegExpMatchArray, + refSegments: string[], + params: Record +): void { + let wildcardNdx = 0, + refNdx = 0; + for ( + let pathNdx = 0; + pathNdx < pathSegments.length && wildcardNdx < wildcards.length; + pathNdx++ + ) { + if (pathSegments[pathNdx].includes('**')) { + const remaining = pathSegments.length - 1 - pathNdx; + const newRefNdx = refSegments.length - 1 - remaining; + if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { + params[trimParam(wildcards[wildcardNdx])] = refSegments + .slice(refNdx, newRefNdx + 1) + .join('/'); + wildcardNdx++; + } + refNdx = newRefNdx + 1; + } else { + if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { + params[trimParam(wildcards[wildcardNdx])] = refSegments[refNdx]; + wildcardNdx++; + } + refNdx++; + } + } +} + /** @internal */ export function makeParams( event: RawRTDBCloudEvent, @@ -193,54 +251,45 @@ export function makeParams( const pathWildcards = path.match(WILDCARD_REGEX); if (pathWildcards) { - const pathParts = path.split('/'); - const eventPathParts = event.ref.split('/'); - for (const wildcard of pathWildcards) { - const trimmedWildcard = trimParam(wildcard); - const position = pathParts.indexOf(wildcard); - params[trimmedWildcard] = eventPathParts[position]; - } + matchParams(path.split('/'), pathWildcards, event.ref.split('/'), params); } const instanceWildcards = instance.match(WILDCARD_REGEX); - /** my-{key}-db or {key}-some-{db} is not allowed */ - if ( - instanceWildcards && - instanceWildcards.length === 1 && - instance.charAt(0) === '{' && - instance.charAt(instance.length - 1) === '}' - ) { - const trimmedWildcard = trimParam(instanceWildcards[0]); - params[trimmedWildcard] = event.instance; + if (instanceWildcards) { + matchParams([instance], instanceWildcards, [event.instance], params); } return params; } +/** @hidden */ function makeDatabaseEvent( event: RawRTDBCloudEvent, instance: string, params: Record ): DatabaseEvent { const snapshot = new DataSnapshot( - event.data.data, + event.data.delta, event.ref, apps().admin, instance ); const databaseEvent: DatabaseEvent = { ...event, + firebaseDatabaseHost: event.firebasedatabasehost, data: snapshot, params, }; + delete (databaseEvent as any).firebasedatabasehost; return databaseEvent; } +/** @hidden */ function makeChangedDatabaseEvent( event: RawRTDBCloudEvent, instance: string, params: Record -) { +): DatabaseEvent> { const before = new DataSnapshot( event.data.data, event.ref, @@ -248,19 +297,21 @@ function makeChangedDatabaseEvent( instance ); const after = new DataSnapshot( - event.data.delta, + applyChange(event.data.data, event.data.delta), event.ref, apps().admin, instance ); const databaseEvent: DatabaseEvent> = { ...event, + firebaseDatabaseHost: event.firebasedatabasehost, data: { before, after, }, params, }; + delete (databaseEvent as any).firebasedatabasehost; return databaseEvent; } @@ -275,18 +326,7 @@ export function onOperation( ): | CloudFunction> | CloudFunction>> { - let path: string, instance: string, opts: options.EventHandlerOptions; - if (typeof referenceOrOpts === 'string') { - path = normalizePath(referenceOrOpts); - instance = '*'; - opts = {}; - } else { - path = normalizePath(referenceOrOpts.ref); - instance = referenceOrOpts.instance || '*'; - opts = { ...referenceOrOpts }; - delete (opts as any).ref; - delete (opts as any).instance; - } + const { path, instance, opts } = getOpts(referenceOrOpts); // wrap the handler const func = (raw: CloudEvent) => { @@ -306,13 +346,11 @@ export function onOperation( const eventFilters: Record = {}; const eventFilterPathPatterns: Record = {}; - if (path.match(WILDCARD_REGEX) || path.includes('*')) { eventFilterPathPatterns.ref = path; } else { eventFilters.ref = path; } - if (instance.match(WILDCARD_REGEX) || instance.includes('*')) { eventFilterPathPatterns.instance = instance; } else { From 4b3ecdf7adaaf16d118af750a74a272f7550aa89 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Fri, 27 May 2022 11:11:40 -0400 Subject: [PATCH 08/16] update tsdoc comments and add changelog --- CHANGELOG.md | 1 + src/v2/providers/database.ts | 88 ++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb..b174a17fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Adds RTDB Triggers for v2 functions (#1127) diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index f3611f33a..ea140e22b 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -55,16 +55,13 @@ export interface RawRTDBCloudEventData { /** @internal */ export interface RawRTDBCloudEvent extends CloudEvent { - /** The domain of the database instance */ firebasedatabasehost: string; - /** The instance ID portion of the fully qualified resource name */ instance: string; - /** The database reference path */ ref: string; - /** The location of the database */ location: string; } +/** A CloudEvent that contains a DataSnapshot or a Change */ export interface DatabaseEvent extends CloudEvent { /** The domain of the database instance */ firebaseDatabaseHost: string; @@ -81,6 +78,7 @@ export interface DatabaseEvent extends CloudEvent { params: Record; } +/** ReferenceOptions extend EventHandlerOptions with provided ref and optional instance */ export interface ReferenceOptions extends options.EventHandlerOptions { /** * Specify the handler to trigger on a database reference(s). @@ -91,19 +89,39 @@ export interface ReferenceOptions extends options.EventHandlerOptions { /** * Specify the handler to trigger on a database instance(s). * If present, this value can either be a single instance or a pattern. - * Examples~ 'my-instance-1', '{instance}', '*' + * Examples~ 'my-instance-1', '{instance}' */ instance?: string; } +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ export function onRefWritten( reference: string, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>>; + +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ export function onRefWritten( opts: ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>>; + +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ export function onRefWritten( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise @@ -116,14 +134,34 @@ export function onRefWritten( ) as CloudFunction>>; } +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ export function onRefCreated( reference: string, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction>; + +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ export function onRefCreated( opts: ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction>; + +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ export function onRefCreated( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise @@ -136,14 +174,34 @@ export function onRefCreated( ) as CloudFunction>; } +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ export function onRefUpdated( reference: string, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>>; + +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ export function onRefUpdated( opts: ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>>; + +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ export function onRefUpdated( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise @@ -156,14 +214,34 @@ export function onRefUpdated( ) as CloudFunction>>; } +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ export function onRefDeleted( reference: string, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction>; + +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ export function onRefDeleted( opts: ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction>; + +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ export function onRefDeleted( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise From f049aaf53ce21e992ac397796de2e5cefde50674 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Fri, 27 May 2022 11:17:50 -0400 Subject: [PATCH 09/16] fixing export path --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0e0b61d8..03f56c851 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "lib/v2/base" ], "v2/database": [ - "lib/v2/database" + "lib/v2/providers/database" ], "v2/eventarc": [ "lib/v2/providers/eventarc" From b3b6864ceb85815001a47c82c0128e1c211b5d85 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Tue, 31 May 2022 10:31:40 -0400 Subject: [PATCH 10/16] cleaning up params code --- src/v2/providers/database.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index ea140e22b..0f3d360af 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -299,23 +299,18 @@ export function matchParams( pathNdx < pathSegments.length && wildcardNdx < wildcards.length; pathNdx++ ) { - if (pathSegments[pathNdx].includes('**')) { - const remaining = pathSegments.length - 1 - pathNdx; - const newRefNdx = refSegments.length - 1 - remaining; - if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { - params[trimParam(wildcards[wildcardNdx])] = refSegments - .slice(refNdx, newRefNdx + 1) - .join('/'); - wildcardNdx++; - } - refNdx = newRefNdx + 1; - } else { - if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { - params[trimParam(wildcards[wildcardNdx])] = refSegments[refNdx]; - wildcardNdx++; - } - refNdx++; + const multiSegmentWildcard = pathSegments[pathNdx].includes('**'); + + const remainingPathSegments = pathSegments.length - 1 - pathNdx; + const nextRefElement = refSegments.length - remainingPathSegments; + + if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { + params[trimParam(wildcards[wildcardNdx++])] = multiSegmentWildcard + ? refSegments.slice(refNdx, nextRefElement).join('/') + : refSegments[refNdx]; } + + refNdx = multiSegmentWildcard ? nextRefElement : refNdx + 1; } } From 6b261c125f347260cc65b05a76785d61bcc3a9ed Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Thu, 2 Jun 2022 14:18:42 -0400 Subject: [PATCH 11/16] ref is always a path pattern --- src/v2/providers/database.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 0f3d360af..8449e4166 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -83,13 +83,13 @@ export interface ReferenceOptions extends options.EventHandlerOptions { /** * Specify the handler to trigger on a database reference(s). * This value can either be a single reference or a pattern. - * Examples~ '/foo/bar', '/foo/{bar} ' + * Examples: '/foo/bar', '/foo/{bar}' */ ref: string; /** * Specify the handler to trigger on a database instance(s). * If present, this value can either be a single instance or a pattern. - * Examples~ 'my-instance-1', '{instance}' + * Examples: 'my-instance-1', '{instance}' */ instance?: string; } @@ -418,12 +418,9 @@ export function onOperation( const specificOpts = options.optionsToEndpoint(opts); const eventFilters: Record = {}; - const eventFilterPathPatterns: Record = {}; - if (path.match(WILDCARD_REGEX) || path.includes('*')) { - eventFilterPathPatterns.ref = path; - } else { - eventFilters.ref = path; - } + const eventFilterPathPatterns: Record = { + ref: path, + }; if (instance.match(WILDCARD_REGEX) || instance.includes('*')) { eventFilterPathPatterns.instance = instance; } else { From 0d21b5eade30f0b10fb1f1a08089e2230e771524 Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Mon, 6 Jun 2022 12:57:20 -0400 Subject: [PATCH 12/16] addressing comments and creating path pattern class --- spec/utilities/path-pattern.spec.ts | 145 +++++++++++++++++++ spec/v2/providers/database.spec.ts | 174 +++-------------------- src/common/providers/database.ts | 46 ++---- src/utilities/path-pattern.ts | 174 +++++++++++++++++++++++ src/v2/providers/database.ts | 213 ++++++++++++---------------- v2/database.js | 26 ++++ 6 files changed, 473 insertions(+), 305 deletions(-) create mode 100644 spec/utilities/path-pattern.spec.ts create mode 100644 src/utilities/path-pattern.ts create mode 100644 v2/database.js diff --git a/spec/utilities/path-pattern.spec.ts b/spec/utilities/path-pattern.spec.ts new file mode 100644 index 000000000..8fe513284 --- /dev/null +++ b/spec/utilities/path-pattern.spec.ts @@ -0,0 +1,145 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +import { expect } from 'chai'; +import * as pathPattern from '../../src/utilities/path-pattern'; + +describe('path-pattern', () => { + describe('trimParam', () => { + it('should trim a capture param without equals', () => { + expect(pathPattern.trimParam('{something}')).to.equal('something'); + }); + + it('should trim a capture param with equals', () => { + expect(pathPattern.trimParam('{something=*}')).to.equal('something'); + }); + }); + + describe('extractMatches', () => { + it('should parse without multi segment', () => { + const pp = new pathPattern.PathPattern('{a}/something/else/{b}/end/{c}'); + + expect( + pp.extractMatches('match_a/something/else/match_b/end/match_c') + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + c: 'match_c', + }); + }); + + it('should parse multi segment with params after', () => { + const pp = new pathPattern.PathPattern( + 'something/**/else/{a}/hello/{b}/world' + ); + + expect( + pp.extractMatches('something/is/a/thing/else/nothing/hello/user/world') + ).to.deep.equal({ + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment param with params after', () => { + const pp = new pathPattern.PathPattern( + 'something/{path=**}/else/{a}/hello/{b}/world' + ); + + expect( + pp.extractMatches('something/is/a/thing/else/nothing/hello/user/world') + ).to.deep.equal({ + path: 'is/a/thing', + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{b}/**/end'); + + expect( + pp.extractMatches( + 'match_a/something/match_b/thing/else/nothing/hello/user/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{b}/{path=**}/end'); + + expect( + pp.extractMatches( + 'match_a/something/match_b/thing/else/nothing/hello/user/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + it('should parse multi segment with params before and after', () => { + const pp = new pathPattern.PathPattern('{a}/something/**/{b}/end'); + + expect( + pp.extractMatches( + 'match_a/something/thing/else/nothing/hello/user/match_b/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{path=**}/{b}/end'); + + expect( + pp.extractMatches( + 'match_a/something/thing/else/nothing/hello/user/match_b/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + // handle an instance param + it('should parse an instance', () => { + const pp = new pathPattern.PathPattern('{a}-something-{b}-else-{c}'); + + expect( + pp.extractMatches('match_a-something-match_b-else-match_c') + ).to.deep.equal({}); + + const anotherPP = new pathPattern.PathPattern('{a}'); + + expect(anotherPP.extractMatches('match_a')).to.deep.equal({ + a: 'match_a', + }); + }); + }); +}); diff --git a/spec/v2/providers/database.spec.ts b/spec/v2/providers/database.spec.ts index 74397b9cd..4ece47c7d 100644 --- a/spec/v2/providers/database.spec.ts +++ b/spec/v2/providers/database.spec.ts @@ -21,6 +21,7 @@ // SOFTWARE. import { expect } from 'chai'; +import { PathPattern } from '../../../src/utilities/path-pattern'; import * as database from '../../../src/v2/providers/database'; const RAW_RTDB_EVENT: database.RawRTDBCloudEvent = { @@ -41,144 +42,6 @@ const RAW_RTDB_EVENT: database.RawRTDBCloudEvent = { type: 'type', }; -describe('matchParams', () => { - it('should parse without multi segment', () => { - const params = {}; - - database.matchParams( - '{a}/something/else/{b}/end/{c}'.split('/'), - ['{a}', '{b}', '{c}'], - 'match_a/something/else/match_b/end/match_c'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'match_a', - b: 'match_b', - c: 'match_c', - }); - }); - - it('should parse multi segment with params after', () => { - const params = {}; - - database.matchParams( - 'something/**/else/{a}/hello/{b}/world'.split('/'), - ['{a}', '{b}'], - 'something/is/a/thing/else/nothing/hello/user/world'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'nothing', - b: 'user', - }); - }); - - it('should parse multi segment param with params after', () => { - const params = {}; - - database.matchParams( - 'something/{path=**}/else/{a}/hello/{b}/world'.split('/'), - ['{path=**}', '{a}', '{b}'], - 'something/is/a/thing/else/nothing/hello/user/world'.split('/'), - params - ); - - expect(params).to.deep.equal({ - path: 'is/a/thing', - a: 'nothing', - b: 'user', - }); - }); - - it('should parse multi segment with params before', () => { - const params = {}; - - database.matchParams( - '{a}/something/{b}/**/end'.split('/'), - ['{a}', '{b}'], - 'match_a/something/match_b/thing/else/nothing/hello/user/end'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'match_a', - b: 'match_b', - }); - }); - - it('should parse multi segment param with params before', () => { - const params = {}; - - database.matchParams( - '{a}/something/{b}/{path=**}/end'.split('/'), - ['{a}', '{b}', '{path=**}'], - 'match_a/something/match_b/thing/else/nothing/hello/user/end'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'match_a', - b: 'match_b', - path: 'thing/else/nothing/hello/user', - }); - }); - - it('should parse multi segment with params before and after', () => { - const params = {}; - - database.matchParams( - '{a}/something/**/{b}/end'.split('/'), - ['{a}', '{b}'], - 'match_a/something/thing/else/nothing/hello/user/match_b/end'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'match_a', - b: 'match_b', - }); - }); - - it('should parse multi segment param with params before', () => { - const params = {}; - - database.matchParams( - '{a}/something/{path=**}/{b}/end'.split('/'), - ['{a}', '{path=**}', '{b}'], - 'match_a/something/thing/else/nothing/hello/user/match_b/end'.split('/'), - params - ); - - expect(params).to.deep.equal({ - a: 'match_a', - b: 'match_b', - path: 'thing/else/nothing/hello/user', - }); - }); - - // handle an instance param - it('should parse an instance', () => { - const params = {}; - - database.matchParams( - ['{a}-something-{b}-else-{c}'], - ['{a}', '{b}', '{c}'], - ['match_a-something-match_b-else-match_c'], - params - ); - - expect(params).to.deep.equal({}); - - database.matchParams(['{a}'], ['{a}'], ['match_a'], params); - - expect(params).to.deep.equal({ - a: 'match_a', - }); - }); -}); - describe('makeParams', () => { it('should make params with basic path', () => { const event: database.RawRTDBCloudEvent = { @@ -187,7 +50,11 @@ describe('makeParams', () => { }; expect( - database.makeParams(event, '{a}/something/else/*/end/{b}', '*') + database.makeParams( + event, + new PathPattern('{a}/something/else/*/end/{b}'), + new PathPattern('*') + ) ).to.deep.equal({ a: 'match_a', b: 'match_b', @@ -201,7 +68,11 @@ describe('makeParams', () => { }; expect( - database.makeParams(event, 'something/**/else/{a}/hello/{b}/world', '*') + database.makeParams( + event, + new PathPattern('something/**/else/{a}/hello/{b}/world'), + new PathPattern('*') + ) ).to.deep.equal({ a: 'match_a', b: 'match_b', @@ -217,8 +88,8 @@ describe('makeParams', () => { expect( database.makeParams( event, - 'something/{path=**}/else/{a}/hello/{b}/world', - '*' + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('*') ) ).to.deep.equal({ path: 'is/a/thing', @@ -236,8 +107,8 @@ describe('makeParams', () => { expect( database.makeParams( event, - 'something/{path=**}/else/{a}/hello/{b}/world', - '{inst}' + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('{inst}') ) ).to.deep.equal({ path: 'is/a/thing', @@ -279,8 +150,7 @@ describe('onOperation', () => { const func = database.onOperation( database.writtenEventType, '/foo/{bar}/', - (event) => 2, - true + (event) => 2 ); expect(func.__endpoint).to.deep.equal({ @@ -302,8 +172,7 @@ describe('onOperation', () => { const func = database.onOperation( database.createdEventType, '/foo/{bar}/', - (event) => 2, - true + (event) => 2 ); expect(func.__endpoint).to.deep.equal({ @@ -325,8 +194,7 @@ describe('onOperation', () => { const func = database.onOperation( database.updatedEventType, '/foo/{bar}/', - (event) => 2, - true + (event) => 2 ); expect(func.__endpoint).to.deep.equal({ @@ -348,8 +216,7 @@ describe('onOperation', () => { const func = database.onOperation( database.deletedEventType, '/foo/{bar}/', - (event) => 2, - true + (event) => 2 ); expect(func.__endpoint).to.deep.equal({ @@ -377,8 +244,7 @@ describe('onOperation', () => { cpu: 'gcf_gen1', minInstances: 2, }, - (event) => 2, - true + (event) => 2 ); expect(func.__endpoint).to.deep.equal({ diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index a8068c782..68000b2e3 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -21,12 +21,13 @@ // SOFTWARE. import * as firebase from 'firebase-admin'; -import { joinPath, /*normalizePath,*/ pathParts } from '../../utilities/path'; +import { firebaseConfig } from '../../config'; +import { joinPath, pathParts } from '../../utilities/path'; /** * Interface representing a Firebase Realtime Database data snapshot. */ -export class DataSnapshot { +export class DataSnapshot implements firebase.database.DataSnapshot { public instance: string; /** @hidden */ @@ -47,6 +48,7 @@ export class DataSnapshot { private app?: firebase.app.App, instance?: string ) { + const config = firebaseConfig(); if (app?.options?.databaseURL?.startsWith('http:')) { // In this case we're dealing with an emulator this.instance = app.options.databaseURL; @@ -55,6 +57,8 @@ export class DataSnapshot { this.instance = instance; } else if (app) { this.instance = app.options.databaseURL; + } else if (config.databaseURL) { + this.instance = config.databaseURL; } else if (process.env.GCLOUD_PROJECT) { this.instance = 'https://' + @@ -93,8 +97,7 @@ export class DataSnapshot { * `DataSnapshot` will return the key for the location that generated it. * However, accessing the key on the root URL of a Database will return `null`. */ - get key(): string { - // const last = _.last(pathParts(this._fullPath())); + get key(): string | null { const segments = pathParts(this._fullPath()); const last = segments[segments.length - 1]; return !last || last === '' ? null : last; @@ -119,10 +122,7 @@ export class DataSnapshot { source = source[part]; } } - const node = typeof source !== 'undefined' ? source : null; - // const node = _.cloneDeep( - // parts.length ? _.get(source, parts, null) : source - // ); + const node = source ?? null; return this._checkAndConvertToArray(node); } @@ -160,7 +160,6 @@ export class DataSnapshot { * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. */ exists(): boolean { - // return !_.isNull(this.val()); return typeof this.val() === 'undefined' || this.val() === null ? false : true; @@ -203,18 +202,9 @@ export class DataSnapshot { * returning `true`. */ forEach(action: (a: DataSnapshot) => boolean | void): boolean { - const val = this.val(); - // if (_.isPlainObject(val)) { - if (typeof val === 'object' && typeof val !== 'function') { - for (const key of Object.keys(val)) { - if (action(this.child(key)) === true) { - return true; - } - } - // return _.some( - // val, - // (value, key: string) => action(this.child(key)) === true - // ); + const val = this.val() || {}; + if (typeof val === 'object') { + return Object.keys(val).some((key) => action(this.child(key)) === true); } return false; } @@ -244,11 +234,8 @@ export class DataSnapshot { */ hasChildren(): boolean { const val = this.val(); - // return _.isPlainObject(val) && _.keys(val).length > 0; return ( - typeof val === 'object' && - typeof val !== 'function' && - Object.keys(val).length > 0 + val !== null && typeof val === 'object' && Object.keys(val).length > 0 ); } @@ -259,8 +246,7 @@ export class DataSnapshot { */ numChildren(): number { const val = this.val(); - // return _.isPlainObject(val) ? Object.keys(val).length : 0; - return typeof val === 'object' && typeof val !== 'function' + return val !== null && typeof val === 'object' ? Object.keys(val).length : 0; } @@ -310,9 +296,6 @@ export class DataSnapshot { for (const key of Object.keys(obj)) { array[key] = obj[key]; } - // _.forOwn(obj, (val, key) => { - // array[key] = val; - // }); return array; } @@ -338,7 +321,6 @@ export class DataSnapshot { /** @hidden */ private _fullPath(): string { - const out = (this._path || '') + '/' + (this._childPath || ''); - return out; + return (this._path || '') + '/' + (this._childPath || ''); } } diff --git a/src/utilities/path-pattern.ts b/src/utilities/path-pattern.ts new file mode 100644 index 000000000..f74c180d9 --- /dev/null +++ b/src/utilities/path-pattern.ts @@ -0,0 +1,174 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { pathParts } from './path'; + +/** https://cloud.google.com/eventarc/docs/path-patterns */ + +/** @hidden */ +const WILDCARD_CAPTURE_REGEX = new RegExp('{[^/{}]*}', 'g'); + +/** @internal */ +export function trimParam(param: string) { + const paramNoBraces = param.slice(1, -1); + if (paramNoBraces.includes('=')) { + return paramNoBraces.slice(0, paramNoBraces.indexOf('=')); + } + return paramNoBraces; +} + +/** @hidden */ +type SegmentName = 'segment' | 'single-capture' | 'multi-capture'; + +/** @hidden */ +interface PathSegment { + readonly name: SegmentName; + readonly value: string; + readonly trimmed: string; + isSingleSegmentWildcard(): boolean; + isMultiSegmentWildcard(): boolean; +} + +/** @hidden */ +class Segment implements PathSegment { + readonly name = 'segment'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = value; + } + isSingleSegmentWildcard(): boolean { + return this.value.includes('*') && !this.isMultiSegmentWildcard(); + } + isMultiSegmentWildcard(): boolean { + return this.value.includes('**'); + } +} + +/** @hidden */ +class SingleCaptureSegment implements PathSegment { + readonly name = 'single-capture'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = trimParam(value); + } + isSingleSegmentWildcard(): boolean { + return true; + } + isMultiSegmentWildcard(): boolean { + return false; + } +} + +/** @hidden */ +class MultiCaptureSegment implements PathSegment { + readonly name = 'multi-capture'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = trimParam(value); + } + isSingleSegmentWildcard(): boolean { + return false; + } + isMultiSegmentWildcard(): boolean { + return true; + } +} + +/** + * Implements Eventarc's path pattern from the spec https://cloud.google.com/eventarc/docs/path-patterns + * @internal + */ +export class PathPattern { + + /** @throws on validation error */ + static compile(raw: string) {} + private segments: PathSegment[]; + + constructor(private raw: string) { + this.initPathSegments(raw); + } + + getValue(): string { + return this.raw; + } + + // If false, we don't need to use pathPattern as our eventarc match type. + hasWildcards(): boolean { + return this.segments.some( + (segment) => + segment.isSingleSegmentWildcard() || segment.isMultiSegmentWildcard() + ); + } + + hasCaptures(): boolean { + return this.segments.some( + (segment) => + segment.name == 'single-capture' || segment.name === 'multi-capture' + ); + } + + extractMatches(path: string): Record { + const matches: Record = {}; + if (!this.hasCaptures()) { + return matches; + } + const pathSegments = pathParts(path); + let pathNdx = 0; + + for ( + let segmentNdx = 0; + segmentNdx < this.segments.length && pathNdx < pathSegments.length; + segmentNdx++ + ) { + const segment = this.segments[segmentNdx]; + const remainingSegments = this.segments.length - 1 - segmentNdx; + const nextPathNdx = pathSegments.length - remainingSegments; + if (segment.name === 'single-capture') { + matches[segment.trimmed] = pathSegments[pathNdx]; + } else if (segment.name === 'multi-capture') { + matches[segment.trimmed] = pathSegments + .slice(pathNdx, nextPathNdx) + .join('/'); + } + pathNdx = segment.isMultiSegmentWildcard() ? nextPathNdx : pathNdx + 1; + } + + return matches; + } + + private initPathSegments(raw: string) { + this.segments = []; + const parts = pathParts(raw); + for (const part of parts) { + let segment: PathSegment; + const capture = part.match(WILDCARD_CAPTURE_REGEX); + if (capture && capture.length === 1) { + segment = part.includes('**') + ? new MultiCaptureSegment(part) + : new SingleCaptureSegment(part); + } else { + segment = new Segment(part); + } + this.segments.push(segment); + } + } +} diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 8449e4166..22222fae7 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -25,15 +25,13 @@ import { Change } from '../../cloud-functions'; import { DataSnapshot } from '../../common/providers/database'; import { ManifestEndpoint } from '../../runtime/manifest'; import { normalizePath } from '../../utilities/path'; +import { PathPattern } from '../../utilities/path-pattern'; import { applyChange } from '../../utils'; import { CloudEvent, CloudFunction } from '../core'; import * as options from '../options'; export { DataSnapshot }; -/** @hidden */ -const WILDCARD_REGEX = new RegExp('{[^/{}]*}', 'g'); - /** @internal */ export const writtenEventType = 'google.firebase.database.ref.v1.written'; @@ -126,12 +124,7 @@ export function onRefWritten( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>> { - return onOperation( - writtenEventType, - referenceOrOpts, - handler, - true - ) as CloudFunction>>; + return onChangedOperation(writtenEventType, referenceOrOpts, handler); } /** @@ -166,12 +159,7 @@ export function onRefCreated( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction> { - return onOperation( - createdEventType, - referenceOrOpts, - handler, - false - ) as CloudFunction>; + return onOperation(createdEventType, referenceOrOpts, handler); } /** @@ -206,12 +194,7 @@ export function onRefUpdated( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent>) => any | Promise ): CloudFunction>> { - return onOperation( - updatedEventType, - referenceOrOpts, - handler, - true - ) as CloudFunction>>; + return onChangedOperation(updatedEventType, referenceOrOpts, handler); } /** @@ -246,12 +229,8 @@ export function onRefDeleted( referenceOrOpts: string | ReferenceOptions, handler: (event: DatabaseEvent) => any | Promise ): CloudFunction> { - return onOperation( - deletedEventType, - referenceOrOpts, - handler, - false - ) as CloudFunction>; + // TODO - need to use event.data.delta + return onOperation(deletedEventType, referenceOrOpts, handler); } /** @internal */ @@ -276,77 +255,26 @@ export function getOpts(referenceOrOpts: string | ReferenceOptions) { }; } -/** @hidden */ -function trimParam(param: string) { - const paramNoBraces = param.slice(1, -1); - if (paramNoBraces.includes('=')) { - return paramNoBraces.slice(0, paramNoBraces.indexOf('=')); - } - return paramNoBraces; -} - -/** @internal */ -export function matchParams( - pathSegments: string[], - wildcards: RegExpMatchArray, - refSegments: string[], - params: Record -): void { - let wildcardNdx = 0, - refNdx = 0; - for ( - let pathNdx = 0; - pathNdx < pathSegments.length && wildcardNdx < wildcards.length; - pathNdx++ - ) { - const multiSegmentWildcard = pathSegments[pathNdx].includes('**'); - - const remainingPathSegments = pathSegments.length - 1 - pathNdx; - const nextRefElement = refSegments.length - remainingPathSegments; - - if (pathSegments[pathNdx] === wildcards[wildcardNdx]) { - params[trimParam(wildcards[wildcardNdx++])] = multiSegmentWildcard - ? refSegments.slice(refNdx, nextRefElement).join('/') - : refSegments[refNdx]; - } - - refNdx = multiSegmentWildcard ? nextRefElement : refNdx + 1; - } -} - /** @internal */ export function makeParams( event: RawRTDBCloudEvent, - path: string, - instance: string -): Record { - const params: Record = {}; - - const pathWildcards = path.match(WILDCARD_REGEX); - if (pathWildcards) { - matchParams(path.split('/'), pathWildcards, event.ref.split('/'), params); - } - - const instanceWildcards = instance.match(WILDCARD_REGEX); - if (instanceWildcards) { - matchParams([instance], instanceWildcards, [event.instance], params); - } - - return params; + path: PathPattern, + instance: PathPattern +) { + return { + ...path.extractMatches(event.ref), + ...instance.extractMatches(event.instance), + }; } /** @hidden */ function makeDatabaseEvent( event: RawRTDBCloudEvent, + data: any, instance: string, params: Record ): DatabaseEvent { - const snapshot = new DataSnapshot( - event.data.delta, - event.ref, - apps().admin, - instance - ); + const snapshot = new DataSnapshot(data, event.ref, apps().admin, instance); const databaseEvent: DatabaseEvent = { ...event, firebaseDatabaseHost: event.firebasedatabasehost, @@ -389,45 +317,30 @@ function makeChangedDatabaseEvent( } /** @internal */ -export function onOperation( +export function createEndpoint( eventType: string, - referenceOrOpts: string | ReferenceOptions, - handler: ( - event: DatabaseEvent | DatabaseEvent> - ) => any | Promise, - changed: boolean -): - | CloudFunction> - | CloudFunction>> { - const { path, instance, opts } = getOpts(referenceOrOpts); - - // wrap the handler - const func = (raw: CloudEvent) => { - const event = raw as RawRTDBCloudEvent; - const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; - const params = makeParams(event, path, instance); - const databaseEvent = changed - ? makeChangedDatabaseEvent(event, instanceUrl, params) - : makeDatabaseEvent(event, instanceUrl, params); - return handler(databaseEvent); - }; - - func.run = handler; - + opts: options.EventHandlerOptions, + path: PathPattern, + instance: PathPattern +): ManifestEndpoint { const baseOpts = options.optionsToEndpoint(options.getGlobalOptions()); const specificOpts = options.optionsToEndpoint(opts); const eventFilters: Record = {}; const eventFilterPathPatterns: Record = { - ref: path, + // Note: Eventarc always treats ref as a path pattern + ref: path.getValue(), }; - if (instance.match(WILDCARD_REGEX) || instance.includes('*')) { - eventFilterPathPatterns.instance = instance; - } else { - eventFilters.instance = instance; - } + instance.hasWildcards() + ? (eventFilterPathPatterns.instance = instance.getValue()) + : (eventFilters.instance = instance.getValue()); + // if (instance.hasWildcards()) { + // eventFilterPathPatterns.instance = instance.getValue(); + // } else { + // eventFilters.instance = instance.getValue(); + // } - const endpoint: ManifestEndpoint = { + return { platform: 'gcfv2', ...baseOpts, ...specificOpts, @@ -442,8 +355,70 @@ export function onOperation( retry: false, }, }; +} - func.__endpoint = endpoint; +/** @internal */ +export function onChangedOperation( + eventType: string, + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + const { path, instance, opts } = getOpts(referenceOrOpts); + + const pathPattern = new PathPattern(path); + const instancePattern = new PathPattern(instance); + + // wrap the handler + const func = (raw: CloudEvent) => { + const event = raw as RawRTDBCloudEvent; + const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; + const params = makeParams(event, pathPattern, instancePattern); + const databaseEvent = makeChangedDatabaseEvent(event, instanceUrl, params); + return handler(databaseEvent); + }; + + func.run = handler; + + func.__endpoint = createEndpoint( + eventType, + opts, + pathPattern, + instancePattern + ); + + return func; +} + +/** @internal */ +export function onOperation( + eventType: string, + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + const { path, instance, opts } = getOpts(referenceOrOpts); + + const pathPattern = new PathPattern(path); + const instancePattern = new PathPattern(instance); + + // wrap the handler + const func = (raw: CloudEvent) => { + const event = raw as RawRTDBCloudEvent; + const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; + const params = makeParams(event, pathPattern, instancePattern); + const data = + eventType === deletedEventType ? event.data.data : event.data.delta; + const databaseEvent = makeDatabaseEvent(event, data, instanceUrl, params); + return handler(databaseEvent); + }; + + func.run = handler; + + func.__endpoint = createEndpoint( + eventType, + opts, + pathPattern, + instancePattern + ); return func; } diff --git a/v2/database.js b/v2/database.js new file mode 100644 index 000000000..c822b56f1 --- /dev/null +++ b/v2/database.js @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// This file is not part of the firebase-functions SDK. It is used to silence the +// imports eslint plugin until it can understand import paths defined by node +// package exports. +// For more information, see github.com/import-js/eslint-plugin-import/issues/1810 \ No newline at end of file From 8afb4c972af09f228805ea68f9e48d9d7f6b000e Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Mon, 6 Jun 2022 13:20:16 -0400 Subject: [PATCH 13/16] linter --- src/common/providers/database.ts | 20 ++++++++++---------- src/utilities/path-pattern.ts | 1 - 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index 68000b2e3..2fdbcb744 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -25,7 +25,7 @@ import { firebaseConfig } from '../../config'; import { joinPath, pathParts } from '../../utilities/path'; /** - * Interface representing a Firebase Realtime Database data snapshot. + * Interface representing a Firebase Realtime database data snapshot. */ export class DataSnapshot implements firebase.database.DataSnapshot { public instance: string; @@ -44,7 +44,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { constructor( data: any, - path?: string, // path will be undefined for the database root + path?: string, // path is undefined for the database root private app?: firebase.app.App, instance?: string ) { @@ -72,7 +72,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { /** * Returns a [`Reference`](/docs/reference/admin/node/admin.database.Reference) - * to the Database location where the triggering write occurred. Has + * to the database location where the triggering write occurred. Has * full read and write access. */ get ref(): firebase.database.Reference { @@ -92,10 +92,10 @@ export class DataSnapshot implements firebase.database.DataSnapshot { /** * The key (last part of the path) of the location of this `DataSnapshot`. * - * The last token in a Database location is considered its key. For example, + * The last token in a database location is considered its key. For example, * "ada" is the key for the `/users/ada/` node. Accessing the key on any - * `DataSnapshot` will return the key for the location that generated it. - * However, accessing the key on the root URL of a Database will return `null`. + * `DataSnapshot` returns the key for the location that generated it. + * However, accessing the key on the root URL of a database returns `null`. */ get key(): string | null { const segments = pathParts(this._fullPath()); @@ -111,7 +111,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { * return `null`, indicating that the `DataSnapshot` is empty (contains no * data). * - * @return The DataSnapshot's contents as a JavaScript value (Object, + * @return The snapshot's contents as a JavaScript value (Object, * Array, string, number, boolean, or `null`). */ val(): any { @@ -189,13 +189,13 @@ export class DataSnapshot implements firebase.database.DataSnapshot { * JavaScript object returned by `val()` is not guaranteed to match the ordering * on the server nor the ordering of `child_added` events. That is where * `forEach()` comes in handy. It guarantees the children of a `DataSnapshot` - * will be iterated in their query order. + * can be iterated in their query order. * * If no explicit `orderBy*()` method is used, results are returned * ordered by key (unless priorities are used, in which case, results are * returned by priority). * - * @param action A function that will be called for each child `DataSnapshot`. + * @param action A function that is called for each child `DataSnapshot`. * The callback can return `true` to cancel further enumeration. * * @return `true` if enumeration was canceled due to your callback @@ -227,7 +227,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { * You can use `hasChildren()` to determine if a `DataSnapshot` has any * children. If it does, you can enumerate them using `forEach()`. If it * doesn't, then either this snapshot contains a primitive value (which can be - * retrieved with `val()`) or it is empty (in which case, `val()` will return + * retrieved with `val()`) or it is empty (in which case, `val()` returns * `null`). * * @return `true` if this snapshot has any children; else `false`. diff --git a/src/utilities/path-pattern.ts b/src/utilities/path-pattern.ts index f74c180d9..bc779ff82 100644 --- a/src/utilities/path-pattern.ts +++ b/src/utilities/path-pattern.ts @@ -98,7 +98,6 @@ class MultiCaptureSegment implements PathSegment { * @internal */ export class PathPattern { - /** @throws on validation error */ static compile(raw: string) {} private segments: PathSegment[]; From 21c27b66e35d3d13f2d310dafaba709c7bbd2f0e Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Mon, 6 Jun 2022 13:39:34 -0400 Subject: [PATCH 14/16] adding describe block --- spec/v2/providers/database.spec.ts | 720 +++++++++++++++-------------- src/v2/providers/database.ts | 5 - 2 files changed, 361 insertions(+), 364 deletions(-) diff --git a/spec/v2/providers/database.spec.ts b/spec/v2/providers/database.spec.ts index 4ece47c7d..217c3f296 100644 --- a/spec/v2/providers/database.spec.ts +++ b/spec/v2/providers/database.spec.ts @@ -42,431 +42,433 @@ const RAW_RTDB_EVENT: database.RawRTDBCloudEvent = { type: 'type', }; -describe('makeParams', () => { - it('should make params with basic path', () => { - const event: database.RawRTDBCloudEvent = { - ...RAW_RTDB_EVENT, - ref: 'match_a/something/else/nothing/end/match_b', - }; - - expect( - database.makeParams( - event, - new PathPattern('{a}/something/else/*/end/{b}'), - new PathPattern('*') - ) - ).to.deep.equal({ - a: 'match_a', - b: 'match_b', +describe('database', () => { + describe('makeParams', () => { + it('should make params with basic path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'match_a/something/else/nothing/end/match_b', + }; + + expect( + database.makeParams( + event, + new PathPattern('{a}/something/else/*/end/{b}'), + new PathPattern('*') + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); }); - }); - it('should make params with multi segment path', () => { - const event: database.RawRTDBCloudEvent = { - ...RAW_RTDB_EVENT, - ref: 'something/is/a/thing/else/match_a/hello/match_b/world', - }; - - expect( - database.makeParams( - event, - new PathPattern('something/**/else/{a}/hello/{b}/world'), - new PathPattern('*') - ) - ).to.deep.equal({ - a: 'match_a', - b: 'match_b', + it('should make params with multi segment path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/**/else/{a}/hello/{b}/world'), + new PathPattern('*') + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); }); - }); - it('should make params with multi segment path capture', () => { - const event: database.RawRTDBCloudEvent = { - ...RAW_RTDB_EVENT, - ref: 'something/is/a/thing/else/match_a/hello/match_b/world', - }; - - expect( - database.makeParams( - event, - new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), - new PathPattern('*') - ) - ).to.deep.equal({ - path: 'is/a/thing', - a: 'match_a', - b: 'match_b', + it('should make params with multi segment path capture', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('*') + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + }); }); - }); - it('should make params for a full path and instance', () => { - const event: database.RawRTDBCloudEvent = { - ...RAW_RTDB_EVENT, - ref: 'something/is/a/thing/else/match_a/hello/match_b/world', - }; - - expect( - database.makeParams( - event, - new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), - new PathPattern('{inst}') - ) - ).to.deep.equal({ - path: 'is/a/thing', - a: 'match_a', - b: 'match_b', - inst: 'my-instance', + it('should make params for a full path and instance', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('{inst}') + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + inst: 'my-instance', + }); }); }); -}); -describe('getOpts', () => { - it('should return opts when passed in a path', () => { - expect(database.getOpts('/foo/{bar}/')).to.deep.equal({ - path: 'foo/{bar}', - instance: '*', - opts: {}, + describe('getOpts', () => { + it('should return opts when passed in a path', () => { + expect(database.getOpts('/foo/{bar}/')).to.deep.equal({ + path: 'foo/{bar}', + instance: '*', + opts: {}, + }); }); - }); - it('should return opts when passed in an options object', () => { - expect( - database.getOpts({ - ref: '/foo/{bar}/', + it('should return opts when passed in an options object', () => { + expect( + database.getOpts({ + ref: '/foo/{bar}/', + instance: '{inst}', + region: 'us-central1', + }) + ).to.deep.equal({ + path: 'foo/{bar}', instance: '{inst}', - region: 'us-central1', - }) - ).to.deep.equal({ - path: 'foo/{bar}', - instance: '{inst}', - opts: { - region: 'us-central1', - }, + opts: { + region: 'us-central1', + }, + }); }); }); -}); -describe('onOperation', () => { - it('should create a function for a written event', () => { - const func = database.onOperation( - database.writtenEventType, - '/foo/{bar}/', - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.writtenEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + describe('onOperation', () => { + it('should create a function for a written event', () => { + const func = database.onOperation( + database.writtenEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function for a created event', () => { - const func = database.onOperation( - database.createdEventType, - '/foo/{bar}/', - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.createdEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + it('should create a function for a created event', () => { + const func = database.onOperation( + database.createdEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function for a updated event', () => { - const func = database.onOperation( - database.updatedEventType, - '/foo/{bar}/', - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.updatedEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + it('should create a function for a updated event', () => { + const func = database.onOperation( + database.updatedEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function for a deleted event', () => { - const func = database.onOperation( - database.deletedEventType, - '/foo/{bar}/', - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.deletedEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + it('should create a function for a deleted event', () => { + const func = database.onOperation( + database.deletedEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a complex function', () => { - const func = database.onOperation( - database.writtenEventType, - { - ref: '/foo/{path=**}/{bar}/', - instance: 'my-instance', - region: 'us-central1', - cpu: 'gcf_gen1', - minInstances: 2, - }, - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - cpu: 'gcf_gen1', - minInstances: 2, - region: ['us-central1'], - labels: {}, - eventTrigger: { - eventType: database.writtenEventType, - eventFilters: { + it('should create a complex function', () => { + const func = database.onOperation( + database.writtenEventType, + { + ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, }, - eventFilterPathPatterns: { - ref: 'foo/{path=**}/{bar}', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, }, - retry: false, - }, + }); }); }); -}); -describe('onRefWritten', () => { - it('should create a function with a reference', () => { - const func = database.onRefWritten('/foo/{bar}/', (event) => 2); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.writtenEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + describe('onRefWritten', () => { + it('should create a function with a reference', () => { + const func = database.onRefWritten('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function with opts', () => { - const func = database.onRefWritten( - { - ref: '/foo/{path=**}/{bar}/', - instance: 'my-instance', - region: 'us-central1', - cpu: 'gcf_gen1', - minInstances: 2, - }, - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - cpu: 'gcf_gen1', - minInstances: 2, - region: ['us-central1'], - labels: {}, - eventTrigger: { - eventType: database.writtenEventType, - eventFilters: { + it('should create a function with opts', () => { + const func = database.onRefWritten( + { + ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, }, - eventFilterPathPatterns: { - ref: 'foo/{path=**}/{bar}', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, }, - retry: false, - }, + }); }); }); -}); -describe('onRefCreated', () => { - it('should create a function with a reference', () => { - const func = database.onRefCreated('/foo/{bar}/', (event) => 2); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.createdEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + describe('onRefCreated', () => { + it('should create a function with a reference', () => { + const func = database.onRefCreated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function with opts', () => { - const func = database.onRefCreated( - { - ref: '/foo/{path=**}/{bar}/', - instance: 'my-instance', - region: 'us-central1', - cpu: 'gcf_gen1', - minInstances: 2, - }, - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - cpu: 'gcf_gen1', - minInstances: 2, - region: ['us-central1'], - labels: {}, - eventTrigger: { - eventType: database.createdEventType, - eventFilters: { + it('should create a function with opts', () => { + const func = database.onRefCreated( + { + ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, }, - eventFilterPathPatterns: { - ref: 'foo/{path=**}/{bar}', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, }, - retry: false, - }, + }); }); }); -}); -describe('onRefUpdated', () => { - it('should create a function with a reference', () => { - const func = database.onRefUpdated('/foo/{bar}/', (event) => 2); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.updatedEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + describe('onRefUpdated', () => { + it('should create a function with a reference', () => { + const func = database.onRefUpdated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function with opts', () => { - const func = database.onRefUpdated( - { - ref: '/foo/{path=**}/{bar}/', - instance: 'my-instance', - region: 'us-central1', - cpu: 'gcf_gen1', - minInstances: 2, - }, - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - cpu: 'gcf_gen1', - minInstances: 2, - region: ['us-central1'], - labels: {}, - eventTrigger: { - eventType: database.updatedEventType, - eventFilters: { + it('should create a function with opts', () => { + const func = database.onRefUpdated( + { + ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, }, - eventFilterPathPatterns: { - ref: 'foo/{path=**}/{bar}', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, }, - retry: false, - }, + }); }); }); -}); -describe('onRefDeleted', () => { - it('should create a function with a reference', () => { - const func = database.onRefDeleted('/foo/{bar}/', (event) => 2); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - labels: {}, - eventTrigger: { - eventType: database.deletedEventType, - eventFilters: {}, - eventFilterPathPatterns: { - ref: 'foo/{bar}', - instance: '*', + describe('onRefDeleted', () => { + it('should create a function with a reference', () => { + const func = database.onRefDeleted('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, }, - retry: false, - }, + }); }); - }); - it('should create a function with opts', () => { - const func = database.onRefDeleted( - { - ref: '/foo/{path=**}/{bar}/', - instance: 'my-instance', - region: 'us-central1', - cpu: 'gcf_gen1', - minInstances: 2, - }, - (event) => 2 - ); - - expect(func.__endpoint).to.deep.equal({ - platform: 'gcfv2', - cpu: 'gcf_gen1', - minInstances: 2, - region: ['us-central1'], - labels: {}, - eventTrigger: { - eventType: database.deletedEventType, - eventFilters: { + it('should create a function with opts', () => { + const func = database.onRefDeleted( + { + ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, }, - eventFilterPathPatterns: { - ref: 'foo/{path=**}/{bar}', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, }, - retry: false, - }, + }); }); }); }); diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 22222fae7..97a4fb879 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -334,11 +334,6 @@ export function createEndpoint( instance.hasWildcards() ? (eventFilterPathPatterns.instance = instance.getValue()) : (eventFilters.instance = instance.getValue()); - // if (instance.hasWildcards()) { - // eventFilterPathPatterns.instance = instance.getValue(); - // } else { - // eventFilters.instance = instance.getValue(); - // } return { platform: 'gcfv2', From 1fc1d33346b1c9da9e1511a674266bd98ee6c9ca Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 8 Jun 2022 16:47:14 -0400 Subject: [PATCH 15/16] adding in changes from @rhodgkins to match the admin SDK --- spec/v1/providers/database.spec.ts | 143 +++++++++++++++++++++++++++-- spec/v2/providers/database.spec.ts | 117 ++++++++++++++++++++--- src/common/providers/database.ts | 26 ++++-- src/utilities/path-pattern.ts | 7 +- src/v2/providers/database.ts | 16 +--- 5 files changed, 266 insertions(+), 43 deletions(-) diff --git a/spec/v1/providers/database.spec.ts b/spec/v1/providers/database.spec.ts index 304c1d1fd..30d24ea3c 100644 --- a/spec/v1/providers/database.spec.ts +++ b/spec/v1/providers/database.spec.ts @@ -639,10 +639,6 @@ describe('Database Functions', () => { expect(subject.val()).to.equal(0); populate({ myKey: 0 }); expect(subject.val()).to.deep.equal({ myKey: 0 }); - - // Null values are still reported as null. - populate({ myKey: null }); - expect(subject.val()).to.deep.equal({ myKey: null }); }); // Regression test: .val() was returning array of nulls when there's a property called length (BUG#37683995) @@ -650,6 +646,45 @@ describe('Database Functions', () => { populate({ length: 3, foo: 'bar' }); expect(subject.val()).to.deep.equal({ length: 3, foo: 'bar' }); }); + + it('should deal with null-values appropriately', () => { + populate(null); + expect(subject.val()).to.be.null; + + populate({ myKey: null }); + expect(subject.val()).to.be.null; + }); + + it('should deal with empty object values appropriately', () => { + populate({}); + expect(subject.val()).to.be.null; + + populate({ myKey: {} }); + expect(subject.val()).to.be.null; + + populate({ myKey: { child: null } }); + expect(subject.val()).to.be.null; + }); + + it('should deal with empty array values appropriately', () => { + populate([]); + expect(subject.val()).to.be.null; + + populate({ myKey: [] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [null] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{}] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{ myKey: null }] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{ myKey: {} }] }); + expect(subject.val()).to.be.null; + }); }); describe('#child(): DataSnapshot', () => { @@ -676,14 +711,37 @@ describe('Database Functions', () => { }); it('should be false for a non-existent value', () => { - populate({ a: { b: 'c' } }); + populate({ a: { b: 'c', nullChild: null } }); expect(subject.child('d').exists()).to.be.false; + expect(subject.child('nullChild').exists()).to.be.false; }); it('should be false for a value pathed beyond a leaf', () => { populate({ a: { b: 'c' } }); expect(subject.child('a/b/c').exists()).to.be.false; }); + + it('should be false for an empty object value', () => { + populate({ a: {} }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: { child: null } }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: { child: {} } }); + expect(subject.child('a').exists()).to.be.false; + }); + + it('should be false for an empty array value', () => { + populate({ a: [] }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: [null] }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: [{}] }); + expect(subject.child('a').exists()).to.be.false; + }); }); describe('#forEach(action: (a: DataSnapshot) => boolean): boolean', () => { @@ -712,6 +770,17 @@ describe('Database Functions', () => { expect(subject.forEach(counter)).to.equal(false); expect(count).to.eq(0); + + populate({ + a: 'foo', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + count = 0; + + expect(subject.forEach(counter)).to.equal(false); + expect(count).to.eq(1); }); it('should cancel further enumeration if callback returns true', () => { @@ -751,13 +820,51 @@ describe('Database Functions', () => { describe('#numChildren()', () => { it('should be key count for objects', () => { - populate({ a: 'b', c: 'd' }); + populate({ + a: 'b', + c: 'd', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.numChildren()).to.eq(2); }); it('should be 0 for non-objects', () => { populate(23); expect(subject.numChildren()).to.eq(0); + + populate({ + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.numChildren()).to.eq(0); + }); + }); + + describe('#hasChildren()', () => { + it('should true for objects', () => { + populate({ + a: 'b', + c: 'd', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.hasChildren()).to.be.true; + }); + + it('should be false for non-objects', () => { + populate(23); + expect(subject.hasChildren()).to.be.false; + + populate({ + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.hasChildren()).to.be.false; }); }); @@ -769,9 +876,17 @@ describe('Database Functions', () => { }); it('should return false if a child is missing', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.hasChild('c')).to.be.false; expect(subject.hasChild('a/b')).to.be.false; + expect(subject.hasChild('nullChild')).to.be.false; + expect(subject.hasChild('emptyObjectChild')).to.be.false; + expect(subject.hasChild('emptyArrayChild')).to.be.false; }); }); @@ -801,11 +916,21 @@ describe('Database Functions', () => { describe('#toJSON(): Object', () => { it('should return the current value', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.toJSON()).to.deep.equal(subject.val()); }); it('should be stringifyable', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(JSON.stringify(subject)).to.deep.equal('{"a":"b"}'); }); }); diff --git a/spec/v2/providers/database.spec.ts b/spec/v2/providers/database.spec.ts index 217c3f296..58d4285ab 100644 --- a/spec/v2/providers/database.spec.ts +++ b/spec/v2/providers/database.spec.ts @@ -146,9 +146,70 @@ describe('database', () => { }); }); - describe('onOperation', () => { + describe('makeEndpoint', () => { + it('should create an endpoint with an instance wildcard', () => { + const ep = database.makeEndpoint( + database.writtenEventType, + { + region: 'us-central1', + labels: { 1: '2' }, + }, + new PathPattern('foo/bar'), + new PathPattern('{inst}') + ); + + expect(ep).to.deep.equal({ + platform: 'gcfv2', + labels: { + 1: '2', + }, + region: ['us-central1'], + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/bar', + instance: '{inst}', + }, + retry: false, + }, + }); + }); + + it('should create an endpoint without an instance wildcard', () => { + const ep = database.makeEndpoint( + database.writtenEventType, + { + region: 'us-central1', + labels: { 1: '2' }, + }, + new PathPattern('foo/bar'), + new PathPattern('my-instance') + ); + + expect(ep).to.deep.equal({ + platform: 'gcfv2', + labels: { + 1: '2', + }, + region: ['us-central1'], + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/bar', + }, + retry: false, + }, + }); + }); + }); + + describe('onChangedOperation', () => { it('should create a function for a written event', () => { - const func = database.onOperation( + const func = database.onChangedOperation( database.writtenEventType, '/foo/{bar}/', (event) => 2 @@ -169,9 +230,9 @@ describe('database', () => { }); }); - it('should create a function for a created event', () => { - const func = database.onOperation( - database.createdEventType, + it('should create a function for a updated event', () => { + const func = database.onChangedOperation( + database.updatedEventType, '/foo/{bar}/', (event) => 2 ); @@ -180,7 +241,7 @@ describe('database', () => { platform: 'gcfv2', labels: {}, eventTrigger: { - eventType: database.createdEventType, + eventType: database.updatedEventType, eventFilters: {}, eventFilterPathPatterns: { ref: 'foo/{bar}', @@ -191,9 +252,43 @@ describe('database', () => { }); }); - it('should create a function for a updated event', () => { + it('should create a complex function', () => { + const func = database.onChangedOperation( + database.writtenEventType, + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onOperation', () => { + it('should create a function for a created event', () => { const func = database.onOperation( - database.updatedEventType, + database.createdEventType, '/foo/{bar}/', (event) => 2 ); @@ -202,7 +297,7 @@ describe('database', () => { platform: 'gcfv2', labels: {}, eventTrigger: { - eventType: database.updatedEventType, + eventType: database.createdEventType, eventFilters: {}, eventFilterPathPatterns: { ref: 'foo/{bar}', @@ -237,7 +332,7 @@ describe('database', () => { it('should create a complex function', () => { const func = database.onOperation( - database.writtenEventType, + database.createdEventType, { ref: '/foo/{path=**}/{bar}/', instance: 'my-instance', @@ -255,7 +350,7 @@ describe('database', () => { region: ['us-central1'], labels: {}, eventTrigger: { - eventType: database.writtenEventType, + eventType: database.createdEventType, eventFilters: { instance: 'my-instance', }, diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index 2fdbcb744..f14ce8b1d 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -130,9 +130,6 @@ export class DataSnapshot implements firebase.database.DataSnapshot { /** * Exports the entire contents of the `DataSnapshot` as a JavaScript object. * - * The `exportVal()` method is similar to `val()`, except priority information - * is included (if available), making it suitable for backing up your data. - * * @return The contents of the `DataSnapshot` as a JavaScript value * (Object, Array, string, number, boolean, or `null`). */ @@ -160,9 +157,14 @@ export class DataSnapshot implements firebase.database.DataSnapshot { * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. */ exists(): boolean { - return typeof this.val() === 'undefined' || this.val() === null - ? false - : true; + const val = this.val(); + if (!val || val === null) { + return false; + } + if (typeof val === 'object' && Object.keys(val).length === 0) { + return false; + } + return true; } /** @@ -280,7 +282,12 @@ export class DataSnapshot implements firebase.database.DataSnapshot { continue; } const childNode = node[key]; - obj[key] = this._checkAndConvertToArray(childNode); + const v = this._checkAndConvertToArray(childNode); + if (v === null) { + // Empty child node + continue; + } + obj[key] = v; numKeys++; const integerRegExp = /^(0|[1-9]\d*)$/; if (allIntegerKeys && integerRegExp.test(key)) { @@ -290,6 +297,11 @@ export class DataSnapshot implements firebase.database.DataSnapshot { } } + if (numKeys === 0) { + // Empty node + return null; + } + if (allIntegerKeys && maxKey < 2 * numKeys) { // convert to array. const array: any = []; diff --git a/src/utilities/path-pattern.ts b/src/utilities/path-pattern.ts index bc779ff82..fadf3bae5 100644 --- a/src/utilities/path-pattern.ts +++ b/src/utilities/path-pattern.ts @@ -25,7 +25,7 @@ import { pathParts } from './path'; /** https://cloud.google.com/eventarc/docs/path-patterns */ /** @hidden */ -const WILDCARD_CAPTURE_REGEX = new RegExp('{[^/{}]*}', 'g'); +const WILDCARD_CAPTURE_REGEX = new RegExp('{[^/{}]+}', 'g'); /** @internal */ export function trimParam(param: string) { @@ -98,11 +98,13 @@ class MultiCaptureSegment implements PathSegment { * @internal */ export class PathPattern { + /** @throws on validation error */ - static compile(raw: string) {} + static compile(rawPath: string) {} private segments: PathSegment[]; constructor(private raw: string) { + this.segments = []; this.initPathSegments(raw); } @@ -155,7 +157,6 @@ export class PathPattern { } private initPathSegments(raw: string) { - this.segments = []; const parts = pathParts(raw); for (const part of parts) { let segment: PathSegment; diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index 97a4fb879..b0a38c313 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -317,7 +317,7 @@ function makeChangedDatabaseEvent( } /** @internal */ -export function createEndpoint( +export function makeEndpoint( eventType: string, opts: options.EventHandlerOptions, path: PathPattern, @@ -374,12 +374,7 @@ export function onChangedOperation( func.run = handler; - func.__endpoint = createEndpoint( - eventType, - opts, - pathPattern, - instancePattern - ); + func.__endpoint = makeEndpoint(eventType, opts, pathPattern, instancePattern); return func; } @@ -408,12 +403,7 @@ export function onOperation( func.run = handler; - func.__endpoint = createEndpoint( - eventType, - opts, - pathPattern, - instancePattern - ); + func.__endpoint = makeEndpoint(eventType, opts, pathPattern, instancePattern); return func; } From bf606f66d3a9e7835cfa0868321db5040deebf9b Mon Sep 17 00:00:00 2001 From: Cole Rogers Date: Wed, 8 Jun 2022 16:50:13 -0400 Subject: [PATCH 16/16] linter --- src/utilities/path-pattern.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utilities/path-pattern.ts b/src/utilities/path-pattern.ts index fadf3bae5..dadc96bb1 100644 --- a/src/utilities/path-pattern.ts +++ b/src/utilities/path-pattern.ts @@ -98,7 +98,6 @@ class MultiCaptureSegment implements PathSegment { * @internal */ export class PathPattern { - /** @throws on validation error */ static compile(rawPath: string) {} private segments: PathSegment[];