diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..8a25fa7c0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- [Added] Support for Firestore Enterprise database index configurations. (#8939) diff --git a/src/firestore/api-sort.ts b/src/firestore/api-sort.ts index 3991a831749..e4df69762f7 100644 --- a/src/firestore/api-sort.ts +++ b/src/firestore/api-sort.ts @@ -9,6 +9,21 @@ const QUERY_SCOPE_SEQUENCE = [ undefined, ]; +const API_SCOPE_SEQUENCE = [ + API.ApiScope.ANY_API, + API.ApiScope.DATASTORE_MODE_API, + API.ApiScope.MONGODB_COMPATIBLE_API, + undefined, +]; + +const DENSITY_SEQUENCE = [ + API.Density.DENSITY_UNSPECIFIED, + API.Density.SPARSE_ALL, + API.Density.SPARSE_ANY, + API.Density.DENSE, + undefined, +]; + const ORDER_SEQUENCE = [API.Order.ASCENDING, API.Order.DESCENDING, undefined]; const ARRAY_CONFIG_SEQUENCE = [API.ArrayConfig.CONTAINS, undefined]; @@ -20,6 +35,10 @@ const ARRAY_CONFIG_SEQUENCE = [API.ArrayConfig.CONTAINS, undefined]; * 1) The collection group. * 2) The query scope. * 3) The fields list. + * 4) The API scope. + * 5) The index density. + * 6) Whether it's multikey. + * 7) Whether it's unique. */ export function compareSpecIndex(a: Spec.Index, b: Spec.Index): number { if (a.collectionGroup !== b.collectionGroup) { @@ -30,7 +49,27 @@ export function compareSpecIndex(a: Spec.Index, b: Spec.Index): number { return compareQueryScope(a.queryScope, b.queryScope); } - return compareArrays(a.fields, b.fields, compareIndexField); + let cmp = compareArrays(a.fields, b.fields, compareIndexField); + if (cmp !== 0) { + return cmp; + } + + cmp = compareApiScope(a.apiScope, b.apiScope); + if (cmp !== 0) { + return cmp; + } + + cmp = compareDensity(a.density, b.density); + if (cmp !== 0) { + return cmp; + } + + cmp = compareBoolean(a.multikey, b.multikey); + if (cmp !== 0) { + return cmp; + } + + return compareBoolean(a.unique, b.unique); } /** @@ -40,6 +79,10 @@ export function compareSpecIndex(a: Spec.Index, b: Spec.Index): number { * 1) The collection group. * 2) The query scope. * 3) The fields list. + * 4) The API scope. + * 5) The index density. + * 6) Whether it's multikey. + * 7) Whether it's unique. */ export function compareApiIndex(a: API.Index, b: API.Index): number { // When these indexes are used as part of a field override, the name is @@ -57,7 +100,27 @@ export function compareApiIndex(a: API.Index, b: API.Index): number { return compareQueryScope(a.queryScope, b.queryScope); } - return compareArrays(a.fields, b.fields, compareIndexField); + let cmp = compareArrays(a.fields, b.fields, compareIndexField); + if (cmp !== 0) { + return cmp; + } + + cmp = compareApiScope(a.apiScope, b.apiScope); + if (cmp !== 0) { + return cmp; + } + + cmp = compareDensity(a.density, b.density); + if (cmp !== 0) { + return cmp; + } + + cmp = compareBoolean(a.multikey, b.multikey); + if (cmp !== 0) { + return cmp; + } + + return compareBoolean(a.unique, b.unique); } /** @@ -215,17 +278,71 @@ function compareFieldIndex(a: Spec.FieldIndex, b: Spec.FieldIndex): number { return compareArrayConfig(a.arrayConfig, b.arrayConfig); } - return 0; + let cmp = compareApiScope(a.apiScope, b.apiScope); + if (cmp !== 0) { + return cmp; + } + + cmp = compareDensity(a.density, b.density); + if (cmp !== 0) { + return cmp; + } + + cmp = compareBoolean(a.multikey, b.multikey); + if (cmp !== 0) { + return cmp; + } + + return compareBoolean(a.unique, b.unique); } function compareQueryScope(a: API.QueryScope, b: API.QueryScope): number { return QUERY_SCOPE_SEQUENCE.indexOf(a) - QUERY_SCOPE_SEQUENCE.indexOf(b); } +function compareApiScope(a?: API.ApiScope, b?: API.ApiScope): number { + if (a === b) { + return 0; + } + if (a === undefined) { + return -1; + } + if (b === undefined) { + return 1; + } + return API_SCOPE_SEQUENCE.indexOf(a) - API_SCOPE_SEQUENCE.indexOf(b); +} + +function compareDensity(a?: API.Density, b?: API.Density): number { + if (a === b) { + return 0; + } + if (a === undefined) { + return -1; + } + if (b === undefined) { + return 1; + } + return DENSITY_SEQUENCE.indexOf(a) - DENSITY_SEQUENCE.indexOf(b); +} + function compareOrder(a?: API.Order, b?: API.Order): number { return ORDER_SEQUENCE.indexOf(a) - ORDER_SEQUENCE.indexOf(b); } +function compareBoolean(a?: boolean, b?: boolean): number { + if (a === b) { + return 0; + } + if (a === undefined) { + return -1; + } + if (b === undefined) { + return 1; + } + return Number(a) - Number(b); +} + function compareArrayConfig(a?: API.ArrayConfig, b?: API.ArrayConfig): number { return ARRAY_CONFIG_SEQUENCE.indexOf(a) - ARRAY_CONFIG_SEQUENCE.indexOf(b); } diff --git a/src/firestore/api-spec.ts b/src/firestore/api-spec.ts index da491b8438a..c3ed1f52984 100644 --- a/src/firestore/api-spec.ts +++ b/src/firestore/api-spec.ts @@ -13,6 +13,10 @@ export interface Index { collectionGroup: string; queryScope: API.QueryScope; fields: API.IndexField[]; + apiScope?: API.ApiScope; + density?: API.Density; + multikey?: boolean; + unique?: boolean; } /** @@ -32,6 +36,10 @@ export interface FieldIndex { queryScope: API.QueryScope; order?: API.Order; arrayConfig?: API.ArrayConfig; + apiScope?: API.ApiScope; + density?: API.Density; + multikey?: boolean; + unique?: boolean; } /** diff --git a/src/firestore/api-types.ts b/src/firestore/api-types.ts index 826cc1ff401..0a933d2b870 100644 --- a/src/firestore/api-types.ts +++ b/src/firestore/api-types.ts @@ -15,6 +15,19 @@ export enum QueryScope { COLLECTION_GROUP = "COLLECTION_GROUP", } +export enum ApiScope { + ANY_API = "ANY_API", + DATASTORE_MODE_API = "DATASTORE_MODE_API", + MONGODB_COMPATIBLE_API = "MONGODB_COMPATIBLE_API", +} + +export enum Density { + DENSITY_UNSPECIFIED = "DENSITY_UNSPECIFIED", + SPARSE_ALL = "SPARSE_ALL", + SPARSE_ANY = "SPARSE_ANY", + DENSE = "DENSE", +} + export enum Order { ASCENDING = "ASCENDING", DESCENDING = "DESCENDING", @@ -49,6 +62,10 @@ export interface Index { queryScope: QueryScope; fields: IndexField[]; state?: State; + apiScope?: ApiScope; + density?: Density; + multikey?: boolean; + unique?: boolean; } /** diff --git a/src/firestore/api.ts b/src/firestore/api.ts index 1e8b147a41f..13564878a44 100644 --- a/src/firestore/api.ts +++ b/src/firestore/api.ts @@ -244,6 +244,10 @@ export class FirestoreApi { collectionGroup: util.parseIndexName(index.name).collectionGroupId, queryScope: index.queryScope, fields: index.fields, + apiScope: index.apiScope, + density: index.density, + multikey: index.multikey, + unique: index.unique, }; }); @@ -266,6 +270,10 @@ export class FirestoreApi { order: firstField.order, arrayConfig: firstField.arrayConfig, queryScope: index.queryScope, + apiScope: index.apiScope, + density: index.density, + multikey: index.multikey, + unique: index.unique, }; }), }; @@ -305,6 +313,22 @@ export class FirestoreApi { validator.assertHas(index, "queryScope"); validator.assertEnum(index, "queryScope", Object.keys(types.QueryScope)); + if (index.apiScope) { + validator.assertEnum(index, "apiScope", Object.keys(types.ApiScope)); + } + + if (index.density) { + validator.assertEnum(index, "density", Object.keys(types.Density)); + } + + if (index.multikey) { + validator.assertType("multikey", index.multikey, "boolean"); + } + + if (index.unique) { + validator.assertType("unique", index.unique, "boolean"); + } + validator.assertHas(index, "fields"); index.fields.forEach((field: any) => { @@ -353,6 +377,22 @@ export class FirestoreApi { if (index.queryScope) { validator.assertEnum(index, "queryScope", Object.keys(types.QueryScope)); } + + if (index.apiScope) { + validator.assertEnum(index, "apiScope", Object.keys(types.ApiScope)); + } + + if (index.density) { + validator.assertEnum(index, "density", Object.keys(types.Density)); + } + + if (index.multikey) { + validator.assertType("multikey", index.multikey, "boolean"); + } + + if (index.unique) { + validator.assertType("unique", index.unique, "boolean"); + } }); } @@ -373,6 +413,10 @@ export class FirestoreApi { const indexes = spec.indexes.map((index) => { return { queryScope: index.queryScope, + apiScope: index.apiScope, + density: index.density, + multikey: index.multikey, + unique: index.unique, fields: [ { fieldPath: spec.fieldPath, @@ -420,6 +464,10 @@ export class FirestoreApi { return this.apiClient.post(url, { fields: index.fields, queryScope: index.queryScope, + apiScope: index.apiScope, + density: index.density, + multikey: index.multikey, + unique: index.unique, }); } @@ -444,6 +492,22 @@ export class FirestoreApi { return false; } + if (index.apiScope !== spec.apiScope) { + return false; + } + + if (index.density !== spec.density) { + return false; + } + + if (index.multikey !== spec.multikey) { + return false; + } + + if (index.unique !== spec.unique) { + return false; + } + if (index.fields.length !== spec.fields.length) { return false; } @@ -465,6 +529,10 @@ export class FirestoreApi { return false; } + if (iField.vectorConfig !== sField.vectorConfig) { + return false; + } + i++; } @@ -549,12 +617,24 @@ export class FirestoreApi { } result.indexes = spec.indexes.map((index: any) => { - const i = { + const i: any = { collectionGroup: index.collectionGroup || index.collectionId, queryScope: index.queryScope || types.QueryScope.COLLECTION, - fields: [], }; + if (index.apiScope) { + i.apiScope = index.apiScope; + } + if (index.density) { + i.density = index.density; + } + if (index.multikey !== undefined) { + i.multikey = index.multikey; + } + if (index.unique !== undefined) { + i.unique = index.unique; + } + if (index.fields) { i.fields = index.fields.map((field: any) => { const f: any = { diff --git a/src/firestore/indexes.spec.ts b/src/firestore/indexes.spec.ts index 129989fe739..637b36d77dd 100644 --- a/src/firestore/indexes.spec.ts +++ b/src/firestore/indexes.spec.ts @@ -36,6 +36,103 @@ describe("IndexValidation", () => { idx.validateSpec(VALID_SPEC); }); + it("should accept a valid index spec with apiScope, density, multikey, and unique", () => { + const spec = { + indexes: [ + { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "ANY_API", + density: "DENSE", + multikey: true, + unique: true, + fields: [ + { fieldPath: "foo", order: "ASCENDING" }, + { fieldPath: "bar", order: "DESCENDING" }, + { fieldPath: "baz", arrayConfig: "CONTAINS" }, + ], + }, + ], + }; + idx.validateSpec(spec); + }); + + it("should reject an index spec with invalid apiScope", () => { + const spec = { + indexes: [ + { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "UNKNOWN", + fields: [], + }, + ], + }; + expect(() => { + idx.validateSpec(spec); + }).to.throw( + FirebaseError, + /Field "apiScope" must be one of ANY_API, DATASTORE_MODE_API, MONGODB_COMPATIBLE_API/, + ); + }); + + it("should reject an index spec with invalid density", () => { + const spec = { + indexes: [ + { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "ANY_API", + density: "UNKNOWN", + fields: [], + }, + ], + }; + expect(() => { + idx.validateSpec(spec); + }).to.throw( + FirebaseError, + /Field "density" must be one of DENSITY_UNSPECIFIED, SPARSE_ALL, SPARSE_ANY, DENSE/, + ); + }); + + it("should reject an index spec with invalid multikey", () => { + const spec = { + indexes: [ + { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "ANY_API", + density: "DENSE", + multikey: "multikey", + fields: [], + }, + ], + }; + expect(() => { + idx.validateSpec(spec); + }).to.throw(FirebaseError, /Property "multikey" must be of type boolean/); + }); + + it("should reject an index spec with invalid unique", () => { + const spec = { + indexes: [ + { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "ANY_API", + density: "DENSE", + multikey: true, + unique: "true", + fields: [], + }, + ], + }; + expect(() => { + idx.validateSpec(spec); + }).to.throw(FirebaseError, /Property "unique" must be of type boolean/); + }); + it("should not change a valid v1beta2 index spec after upgrade", () => { const upgraded = idx.upgradeOldSpec(VALID_SPEC); expect(upgraded).to.eql(VALID_SPEC); @@ -229,6 +326,177 @@ describe("IndexSpecMatching", () => { expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(true); }); + it("should identify a positive index spec match with apiScope, density, multikey, and unique", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + apiScope: API.ApiScope.ANY_API, + density: API.Density.DENSE, + multikey: true, + unique: true, + fields: [ + { fieldPath: "foo", order: API.Order.ASCENDING }, + { fieldPath: "bar", arrayConfig: API.ArrayConfig.CONTAINS }, + ], + state: API.State.READY, + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "ANY_API", + density: "DENSE", + multikey: true, + unique: true, + fields: [ + { fieldPath: "foo", order: "ASCENDING" }, + { fieldPath: "bar", arrayConfig: "CONTAINS" }, + ], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(true); + }); + + it("should identify a negative index spec match with different apiScope", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + apiScope: API.ApiScope.ANY_API, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "DATASTORE_MODE_API", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with missing apiScope", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + apiScope: "DATASTORE_MODE_API", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with different density", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + density: API.Density.DENSE, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + density: "SPARSE_ALL", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with missing density", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + density: API.Density.DENSE, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with different multikey", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + multikey: true, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + multikey: false, + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with missing multikey", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + multikey: false, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with different unique", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + unique: true, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + unique: false, + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + + it("should identify a negative index spec match with missing unique", () => { + const apiIndex: API.Index = { + name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", + queryScope: API.QueryScope.COLLECTION, + unique: false, + fields: [], + }; + + const specIndex = { + collectionGroup: "collection", + queryScope: "COLLECTION", + fields: [], + } as Spec.Index; + + expect(idx.indexMatchesSpec(apiIndex, specIndex)).to.eql(false); + }); + it("should identify a negative index spec match", () => { const apiIndex = { name: "/projects/myproject/databases/(default)/collectionGroups/collection/indexes/abc123", @@ -661,6 +929,121 @@ describe("IndexSorting", () => { expect([b, a, e, d, c].sort(sort.compareSpecIndex)).to.eql([a, b, c, d, e]); }); + it("should correctly sort an array of Spec indexes with apiScope, density, multikey, and unique", () => { + const a: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.ANY_API, + }; + + const b: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.ANY_API, + }; + + const c: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.DATASTORE_MODE_API, + }; + + const d: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + }; + + const e: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSITY_UNSPECIFIED, + }; + + const f: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.SPARSE_ALL, + }; + + const g: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.SPARSE_ANY, + }; + + const h: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSE, + }; + + const i: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSE, + multikey: false, + }; + + const j: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSE, + multikey: true, + }; + + const k: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSE, + multikey: true, + unique: false, + }; + + const l: Spec.Index = { + collectionGroup: "collectionA", + queryScope: API.QueryScope.COLLECTION, + fields: [], + apiScope: API.ApiScope.MONGODB_COMPATIBLE_API, + density: API.Density.DENSE, + multikey: true, + unique: true, + }; + + expect([l, k, j, i, h, g, f, e, d, c, b, a].sort(sort.compareSpecIndex)).to.eql([ + a, + b, + c, + d, + e, + f, + g, + h, + i, + j, + k, + l, + ]); + }); + it("should correcty sort an array of Spec field overrides", () => { // Sorts first because of collectionGroup const a: Spec.FieldOverride = { diff --git a/src/firestore/validator.ts b/src/firestore/validator.ts index 87a3169c141..b3d4a7a43a2 100644 --- a/src/firestore/validator.ts +++ b/src/firestore/validator.ts @@ -36,7 +36,7 @@ export function assertHasOneOf(obj: any, props: string[]): void { export function assertEnum(obj: any, prop: string, valid: any[]): void { const objString = clc.cyan(JSON.stringify(obj)); if (valid.indexOf(obj[prop]) < 0) { - throw new FirebaseError(`Field "${prop}" must be one of ${valid.join(", ")}: ${objString}`); + throw new FirebaseError(`Field "${prop}" must be one of ${valid.join(", ")}: ${objString}`); } } diff --git a/templates/init/firestore/firestore.indexes.json b/templates/init/firestore/firestore.indexes.json index 4ff4fab5c7c..0e6de7cb808 100644 --- a/templates/init/firestore/firestore.indexes.json +++ b/templates/init/firestore/firestore.indexes.json @@ -1,5 +1,5 @@ { - // Example: + // Example (Standard Edition): // // "indexes": [ // { @@ -21,6 +21,31 @@ // }, // ] // ] + // + // Example (Enterprise Edition): + // + // "indexes": [ + // { + // "collectionGroup": "reviews", + // "queryScope": "COLLECTION_GROUP", + // "apiScope": "MONGODB_COMPATIBLE_API", + // "density": "DENSE", + // "multikey": false, + // "fields": [ + // { "fieldPath": "baz", "mode": "ASCENDING" } + // ] + // }, + // { + // "collectionGroup": "items", + // "queryScope": "COLLECTION_GROUP", + // "apiScope": "MONGODB_COMPATIBLE_API", + // "density": "SPARSE_ANY", + // "multikey": true, + // "fields": [ + // { "fieldPath": "baz", "mode": "ASCENDING" } + // ] + // }, + // ] "indexes": [], "fieldOverrides": [] } \ No newline at end of file