Skip to content

Commit d6fa753

Browse files
authored
fix(firestore-bigquery-export): normalize NONE/omit partitioning params for change-tracker 2.x (#2698)
* fix(firestore-bigquery-export): implement buildPartitioningConfig function and update tests * fix(tests): improve readability and structure of partitioning tests * chore(firebase-bigquery-export): release version 0.3.0 with breaking changes and improved partitioning validation
1 parent eeb8729 commit d6fa753

7 files changed

Lines changed: 321 additions & 89 deletions

File tree

firestore-bigquery-export/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## Version 0.3.0
2+
3+
breaking change: reject invalid partitioning configuration combinations at startup with explicit error messages
4+
5+
fix: normalize `NONE` / `omit` partitioning sentinels before mapping to change-tracker 2.x partitioning strategy
6+
17
## Version 0.2.11
28

39
chore: bump firestore-bigquery-change-tracker dependency to v2 in functions package

firestore-bigquery-export/extension.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515
name: firestore-bigquery-export
16-
version: 0.2.11
16+
version: 0.3.0
1717
specVersion: v1beta
1818

1919
displayName: Stream Firestore to BigQuery

firestore-bigquery-export/firestore-bigquery-change-tracker/src/__tests__/bigquery/partitioning.test.ts

Lines changed: 89 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ let dataset: Dataset;
1515
let table: Table;
1616
let randomID: string;
1717
let datasetId: string;
18+
const describeIfBigQueryIntegration =
19+
process.env.RUN_BIGQUERY_INTEGRATION_TESTS === "true"
20+
? describe
21+
: describe.skip;
1822

19-
describe("processing partitions on a new table", () => {
23+
describeIfBigQueryIntegration("processing partitions on a new table", () => {
2024
beforeAll(async () => {
2125
jest.spyOn(logger, "debug").mockImplementation(() => {});
2226
jest.spyOn(logger, "info").mockImplementation(() => {});
@@ -576,7 +580,7 @@ describe("processing partitions on a new table", () => {
576580
});
577581
});
578582

579-
describe("updateTableMetadata", () => {
583+
describeIfBigQueryIntegration("updateTableMetadata", () => {
580584
let testTable: Table;
581585
let testDataset: Dataset;
582586

@@ -728,103 +732,106 @@ describe("updateTableMetadata", () => {
728732
});
729733
});
730734

731-
describe("getPartitionValue with DELETE operations", () => {
732-
let testTable: Table;
733-
let testDataset: Dataset;
734-
735-
beforeAll(async () => {
736-
const randomID = (Math.random() + 1).toString(36).substring(7);
737-
const testDatasetId = `bq_delete_${randomID}`;
738-
[testDataset] = await bq.createDataset(testDatasetId, {
739-
location: "europe-west2",
740-
});
741-
[testTable] = await testDataset.createTable(`bq_delete_${randomID}`, {});
742-
});
743-
744-
afterAll(async () => {
745-
await deleteTable({
746-
datasetId: testDataset.id,
735+
describeIfBigQueryIntegration(
736+
"getPartitionValue with DELETE operations",
737+
() => {
738+
let testTable: Table;
739+
let testDataset: Dataset;
740+
741+
beforeAll(async () => {
742+
const randomID = (Math.random() + 1).toString(36).substring(7);
743+
const testDatasetId = `bq_delete_${randomID}`;
744+
[testDataset] = await bq.createDataset(testDatasetId, {
745+
location: "europe-west2",
746+
});
747+
[testTable] = await testDataset.createTable(`bq_delete_${randomID}`, {});
747748
});
748-
});
749749

750-
test("uses oldData for DELETE operations", () => {
751-
const partitioningConfig = new PartitioningConfig({
752-
granularity: "DAY",
753-
bigqueryColumnName: "end_date",
754-
bigqueryColumnType: "TIMESTAMP",
755-
firestoreFieldName: "endDate",
750+
afterAll(async () => {
751+
await deleteTable({
752+
datasetId: testDataset.id,
753+
});
756754
});
757755

758-
const oldDate = admin.firestore.Timestamp.fromDate(
759-
new Date("2024-01-15T10:00:00Z")
760-
);
756+
test("uses oldData for DELETE operations", () => {
757+
const partitioningConfig = new PartitioningConfig({
758+
granularity: "DAY",
759+
bigqueryColumnName: "end_date",
760+
bigqueryColumnType: "TIMESTAMP",
761+
firestoreFieldName: "endDate",
762+
});
761763

762-
const event: FirestoreDocumentChangeEvent = {
763-
timestamp: "",
764-
operation: ChangeType.DELETE,
765-
documentName: "test/doc",
766-
eventId: "event1",
767-
documentId: "doc",
768-
data: null,
769-
oldData: { endDate: oldDate },
770-
};
764+
const oldDate = admin.firestore.Timestamp.fromDate(
765+
new Date("2024-01-15T10:00:00Z")
766+
);
771767

772-
const partitioning = new Partitioning(partitioningConfig, testTable);
773-
const value = partitioning.getPartitionValue(event);
768+
const event: FirestoreDocumentChangeEvent = {
769+
timestamp: "",
770+
operation: ChangeType.DELETE,
771+
documentName: "test/doc",
772+
eventId: "event1",
773+
documentId: "doc",
774+
data: null,
775+
oldData: { endDate: oldDate },
776+
};
774777

775-
expect(value.end_date).toBeDefined();
776-
});
778+
const partitioning = new Partitioning(partitioningConfig, testTable);
779+
const value = partitioning.getPartitionValue(event);
777780

778-
test("returns empty object for DELETE when oldData is null", () => {
779-
const partitioningConfig = new PartitioningConfig({
780-
granularity: "DAY",
781-
bigqueryColumnName: "end_date",
782-
bigqueryColumnType: "TIMESTAMP",
783-
firestoreFieldName: "endDate",
781+
expect(value.end_date).toBeDefined();
784782
});
785783

786-
const event: FirestoreDocumentChangeEvent = {
787-
timestamp: "",
788-
operation: ChangeType.DELETE,
789-
documentName: "test/doc",
790-
eventId: "event1",
791-
documentId: "doc",
792-
data: null,
793-
oldData: null,
794-
};
784+
test("returns empty object for DELETE when oldData is null", () => {
785+
const partitioningConfig = new PartitioningConfig({
786+
granularity: "DAY",
787+
bigqueryColumnName: "end_date",
788+
bigqueryColumnType: "TIMESTAMP",
789+
firestoreFieldName: "endDate",
790+
});
795791

796-
const partitioning = new Partitioning(partitioningConfig, testTable);
797-
const value = partitioning.getPartitionValue(event);
792+
const event: FirestoreDocumentChangeEvent = {
793+
timestamp: "",
794+
operation: ChangeType.DELETE,
795+
documentName: "test/doc",
796+
eventId: "event1",
797+
documentId: "doc",
798+
data: null,
799+
oldData: null,
800+
};
798801

799-
expect(value).toEqual({});
800-
});
802+
const partitioning = new Partitioning(partitioningConfig, testTable);
803+
const value = partitioning.getPartitionValue(event);
801804

802-
test("returns empty object for DELETE when oldData lacks the field", () => {
803-
const partitioningConfig = new PartitioningConfig({
804-
granularity: "DAY",
805-
bigqueryColumnName: "end_date",
806-
bigqueryColumnType: "TIMESTAMP",
807-
firestoreFieldName: "endDate",
805+
expect(value).toEqual({});
808806
});
809807

810-
const event: FirestoreDocumentChangeEvent = {
811-
timestamp: "",
812-
operation: ChangeType.DELETE,
813-
documentName: "test/doc",
814-
eventId: "event1",
815-
documentId: "doc",
816-
data: null,
817-
oldData: { otherField: "value" },
818-
};
808+
test("returns empty object for DELETE when oldData lacks the field", () => {
809+
const partitioningConfig = new PartitioningConfig({
810+
granularity: "DAY",
811+
bigqueryColumnName: "end_date",
812+
bigqueryColumnType: "TIMESTAMP",
813+
firestoreFieldName: "endDate",
814+
});
819815

820-
const partitioning = new Partitioning(partitioningConfig, testTable);
821-
const value = partitioning.getPartitionValue(event);
816+
const event: FirestoreDocumentChangeEvent = {
817+
timestamp: "",
818+
operation: ChangeType.DELETE,
819+
documentName: "test/doc",
820+
eventId: "event1",
821+
documentId: "doc",
822+
data: null,
823+
oldData: { otherField: "value" },
824+
};
822825

823-
expect(value).toEqual({});
824-
});
825-
});
826+
const partitioning = new Partitioning(partitioningConfig, testTable);
827+
const value = partitioning.getPartitionValue(event);
828+
829+
expect(value).toEqual({});
830+
});
831+
}
832+
);
826833

827-
describe("isValidPartitionForExistingTable", () => {
834+
describeIfBigQueryIntegration("isValidPartitionForExistingTable", () => {
828835
let testTable: Table;
829836
let testDataset: Dataset;
830837
let partitionedTable: Table;
@@ -886,7 +893,7 @@ describe("isValidPartitionForExistingTable", () => {
886893
});
887894
});
888895

889-
describe("addPartitioningToSchema with DATE type", () => {
896+
describeIfBigQueryIntegration("addPartitioningToSchema with DATE type", () => {
890897
let testTable: Table;
891898
let testDataset: Dataset;
892899

firestore-bigquery-export/functions/__tests__/__snapshots__/config.test.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ Object {
2626
"maxDispatchesPerSecond": 10,
2727
"maxEnqueueAttempts": 3,
2828
"maxStaleness": undefined,
29+
"partitioning": Object {
30+
"granularity": "NONE",
31+
},
2932
"projectId": undefined,
3033
"refreshIntervalMinutes": undefined,
3134
"tableId": "my_table",

firestore-bigquery-export/functions/__tests__/config.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resolve as pathResolve } from "path";
55
import * as yaml from "js-yaml";
66
import mockedEnv from "mocked-env";
77

8-
import { clustering } from "../src/config";
8+
import { buildPartitioningConfig, clustering } from "../src/config";
99

1010
let restoreEnv;
1111
let extensionYaml;
@@ -150,4 +150,105 @@ describe("extension config", () => {
150150
});
151151
});
152152
});
153+
154+
describe("buildPartitioningConfig", () => {
155+
test("uses ingestion-time partitioning when optional partition fields are empty", () => {
156+
expect(
157+
buildPartitioningConfig({
158+
timePartitioning: "HOUR",
159+
timePartitioningField: undefined,
160+
timePartitioningFieldType: undefined,
161+
timePartitioningFirestoreField: undefined,
162+
})
163+
).toEqual({
164+
granularity: "HOUR",
165+
});
166+
});
167+
168+
test("normalizes NONE/omit sentinels to ingestion-time partitioning", () => {
169+
expect(
170+
buildPartitioningConfig({
171+
timePartitioning: "DAY",
172+
timePartitioningField: "NONE",
173+
timePartitioningFieldType: "omit",
174+
timePartitioningFirestoreField: "NONE",
175+
})
176+
).toEqual({
177+
granularity: "DAY",
178+
});
179+
});
180+
181+
test("returns NONE strategy when table partitioning is disabled and optional fields are empty", () => {
182+
expect(
183+
buildPartitioningConfig({
184+
timePartitioning: null,
185+
timePartitioningField: undefined,
186+
timePartitioningFieldType: undefined,
187+
timePartitioningFirestoreField: undefined,
188+
})
189+
).toEqual({
190+
granularity: "NONE",
191+
});
192+
});
193+
194+
test("returns custom Firestore field strategy only when all required fields exist", () => {
195+
expect(
196+
buildPartitioningConfig({
197+
timePartitioning: "HOUR",
198+
timePartitioningField: "partition_column",
199+
timePartitioningFieldType: "TIMESTAMP",
200+
timePartitioningFirestoreField: "time",
201+
})
202+
).toEqual({
203+
granularity: "HOUR",
204+
bigqueryColumnName: "partition_column",
205+
bigqueryColumnType: "TIMESTAMP",
206+
firestoreFieldName: "time",
207+
});
208+
});
209+
210+
test("supports partitioning by the built-in timestamp field", () => {
211+
expect(
212+
buildPartitioningConfig({
213+
timePartitioning: "MONTH",
214+
timePartitioningField: "timestamp",
215+
timePartitioningFieldType: "DATETIME",
216+
timePartitioningFirestoreField: undefined,
217+
})
218+
).toEqual({
219+
granularity: "MONTH",
220+
bigqueryColumnName: "timestamp",
221+
bigqueryColumnType: "DATETIME",
222+
});
223+
});
224+
225+
test("throws a useful error for invalid partial custom field configs", () => {
226+
expect(() =>
227+
buildPartitioningConfig({
228+
timePartitioning: "HOUR",
229+
timePartitioningField: "partition_column",
230+
timePartitioningFieldType: "omit",
231+
timePartitioningFirestoreField: undefined,
232+
})
233+
).toThrow(/Invalid partitioning configuration/);
234+
235+
expect(() =>
236+
buildPartitioningConfig({
237+
timePartitioning: "HOUR",
238+
timePartitioningField: undefined,
239+
timePartitioningFieldType: "TIMESTAMP",
240+
timePartitioningFirestoreField: "time",
241+
})
242+
).toThrow(/Valid combinations are/);
243+
244+
expect(() =>
245+
buildPartitioningConfig({
246+
timePartitioning: null,
247+
timePartitioningField: "created_at",
248+
timePartitioningFieldType: "TIMESTAMP",
249+
timePartitioningFirestoreField: "createdAt",
250+
})
251+
).toThrow(/TABLE_PARTITIONING is NONE/);
252+
});
253+
});
153254
});

0 commit comments

Comments
 (0)