Skip to content

Commit

Permalink
feat: added firestoreSubObjectField()
Browse files Browse the repository at this point in the history
  • Loading branch information
dereekb committed Jun 29, 2022
1 parent 31a4246 commit 3d6fbe1
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 81 deletions.
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './snapshot';
export * from './snapshot.field';
export * from './snapshot.type';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isValid } from 'date-fns';
import { FirestoreModelKeyGrantedRoleArrayMap } from '../collection';
import { DocumentSnapshot } from '../types';
import { snapshotConverterFunctions } from './snapshot';
import { firestoreArrayMap, firestoreDate, firestoreFieldMapArray, firestoreEnum, firestoreField, firestoreMap, firestoreModelKeyGrantedRoleArrayMap, firestoreEnumArray, firestoreUniqueKeyedArray, firestoreUniqueStringArray, firestoreNumber } from './snapshot.field';
import { firestoreArrayMap, firestoreDate, firestoreFieldMapArray, firestoreEnum, firestoreField, firestoreMap, firestoreModelKeyGrantedRoleArrayMap, firestoreEnumArray, firestoreUniqueKeyedArray, firestoreUniqueStringArray, firestoreNumber, firestoreSubObjectField } from './snapshot.field';

describe('firestoreField()', () => {
const defaultValue = -1;
Expand Down Expand Up @@ -96,6 +96,14 @@ describe('firestoreDate()', () => {
expect(converted).toBeDefined();
expect(converted).toBe(dateString);
});

describe('saveDefaultAsNow = true', () => {
it('should return a date for now if the date is undefined or null', () => {
const result = testSnapshotDefaultsConverter.mapFunctions.from({} as any);
const date = result.date;
expect(date).toBeDefined();
});
});
});

describe('firestoreNumber()', () => {
Expand Down Expand Up @@ -268,3 +276,94 @@ describe('firestoreModelKeyGrantedRoleArrayMap()', () => {
expect(objectHasKey(results, 'emptymodelpath')).toBe(false);
});
});

export interface TestFirestoreSubObjectParent {
object: TestFirestoreSubObject;
}

export interface TestFirestoreSubObject {
date: Date;
uniqueStringArray: string[];
}

describe('firestoreSubObjectField()', () => {
const testFirestoreSubObjectField = firestoreSubObjectField<TestFirestoreSubObject>({
objectField: testSnapshotDefaultsConverter
});

const testFirestoreSubObjectFieldConverter = snapshotConverterFunctions<TestFirestoreSubObjectParent>({
fields: {
object: testFirestoreSubObjectField
}
});

describe('converter', () => {
const testObject = {
date: new Date(),
uniqueStringArray: ['a', 'b']
};

const parent = {
object: testObject
};

it('should convert from an empty data object and return the default value', () => {
const result = testFirestoreSubObjectFieldConverter.mapFunctions.from({});

expect(result).toBeDefined();
expect(result.object).toBeDefined();
expect(result.object.date).toBeDefined();
expect(result.object.uniqueStringArray).toBeDefined();
expect(result.object.uniqueStringArray.length).toBe(0);
});

it('should convert an object and back.', () => {
const data = testFirestoreSubObjectFieldConverter.mapFunctions.to(parent);

expect(data).toBeDefined();
expect(data.object).toBeDefined();
expect(data.object.date).toBeDefined();
expect(data.object.uniqueStringArray).toBeDefined();

const result = testFirestoreSubObjectFieldConverter.mapFunctions.from(data);

expect(result).toBeDefined();
expect(result.object).toBeDefined();
expect(result.object.date).toBeDefined();
expect(result.object.date).toBeSameSecondAs(testObject.date);
expect(result.object.uniqueStringArray).toBeDefined();
expect(result.object.uniqueStringArray).toContain(testObject.uniqueStringArray[0]);
expect(result.object.uniqueStringArray).toContain(testObject.uniqueStringArray[1]);
});

describe('with saveDefaultObject unset', () => {
it('should convert an empty value to an empty object and have null for the embedded object.', () => {
const result = testFirestoreSubObjectFieldConverter.mapFunctions.to({} as typeof parent);

expect(result).toBeDefined();
expect(result.object).toBe(null);
});
});

describe('with saveDefaultObject=true', () => {
const testFirestoreSubObjectFieldConverterWithSaveDefaultObject = snapshotConverterFunctions<TestFirestoreSubObjectParent>({
fields: {
object: firestoreSubObjectField<TestFirestoreSubObject>({
objectField: testSnapshotDefaultsConverter,
saveDefaultObject: true
})
}
});

it('should convert an empty value to an object with a default value for the embedded object.', () => {
const result = testFirestoreSubObjectFieldConverterWithSaveDefaultObject.mapFunctions.to({} as typeof parent);

expect(result).toBeDefined();
expect(result.object).not.toBe(null);
expect(result.object).toBeDefined();
expect(result.object.date).toBeDefined();
expect(result.object.uniqueStringArray).toBeDefined();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ import {
filterEmptyValues,
ModelKey,
unique,
Getter
Getter,
ToModelFieldConversionsInput,
toModelFieldConversions,
makeModelMapFunctions,
ToModelMapFunctionsInput,
toModelMapFunctions
} from '@dereekb/util';
import { FIRESTORE_EMPTY_VALUE } from './snapshot';
import { FirestoreModelData, FIRESTORE_EMPTY_VALUE } from './snapshot.type';
import { FirebaseAuthUserId } from '../../auth/auth';

export interface BaseFirestoreFieldConfig<V, D = unknown> {
Expand Down Expand Up @@ -391,6 +396,39 @@ export function firestoreModelKeyGrantedRoleArrayMap<R extends GrantedRole>() {
*/
export const firestoreModelIdGrantedRoleArrayMap: () => FirestoreModelFieldMapFunctionsConfig<FirestoreMapFieldType<ModelKey[], string>, FirestoreMapFieldType<ModelKey[], string>> = firestoreModelKeyGrantedRoleArrayMap;

/**
* firestoreSubObjectField configuration
*/
export type FirestoreSubObjectFieldConfig<T extends object, O extends object = FirestoreModelData<T>> = DefaultMapConfiguredFirestoreFieldConfig<T, O> & {
/**
* Whether or not to save the default object. Is ignored if defaultBeforeSave is set.
*
* Is false by default.
*/
saveDefaultObject?: boolean;
/**
* The fields to use for conversion.
*/
objectField: ToModelMapFunctionsInput<T, O>;
};

/**
* A nested object field that uses other FirestoreFieldConfig configurations to map a field.
*/
export function firestoreSubObjectField<T extends object, O extends object = FirestoreModelData<T>>(config: FirestoreSubObjectFieldConfig<T, O>) {
const { from: fromData, to: toData } = toModelMapFunctions<T, O>(config.objectField);

const defaultWithFields: Getter<T> = () => fromData({} as O);
const defaultBeforeSave = config.defaultBeforeSave ?? (config.saveDefaultObject ? () => toData({} as T) : null);

return firestoreField<T, O>({
default: config.default ?? defaultWithFields,
defaultBeforeSave,
fromData,
toData
});
}

// MARK: Deprecated
export type FirestoreSetFieldConfig<T extends string | number> = DefaultMapConfiguredFirestoreFieldConfig<Set<T>, T[]>;

Expand Down
83 changes: 7 additions & 76 deletions packages/firebase/src/lib/common/firestore/snapshot/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,13 @@
import { MaybeMap, ModelFieldConversions, makeModelMapFunctions, Maybe, ApplyMapFunctionWithOptions, ModelConversionOptions, ModelFieldConversionsConfig, modelFieldConversions, TypedMappedModelData, ArrayOrValue, modifyModelMapFunctions, PartialModelModifier } from '@dereekb/util';
import { MaybeMap, ModelFieldConversions, makeModelMapFunctions, Maybe, ApplyMapFunctionWithOptions, ModelConversionOptions, ModelFieldConversionsConfig, modelFieldConversions, TypedMappedModelData, ArrayOrValue, modifyModelMapFunctions, PartialModelModifier, ModelFieldConversionsRef, toModelFieldConversions, ModelFieldConversionsConfigRef } from '@dereekb/util';
import { PartialWithFieldValue, SnapshotOptions, SetOptions, WithFieldValue, DocumentSnapshot, FirestoreDataConverter, SetOptionsMerge, SetOptionsMergeFields, asTopLevelFieldPaths } from '../types';

// MARK: Type
/**
* The default "empty" value in the Firestore.
*/
export const FIRESTORE_EMPTY_VALUE = null;

/**
* The expected firestore document data for a specific type.
*
* This declaration allows a second type to defined overrides for how the data is stored within Firestore. For example, since by default
* this library choses to store dates as an ISO8601String, you can strictly specify that, and gain the type checking benefits.
*/
export type ExpectedFirestoreModelData<T extends object, R extends object = object> = TypedMappedModelData<T, R>;

/**
* What is considered the typings for the true "stored" data.
*
* All items are marked as partial and Maybe. This is because by design the firestore has no schema and has no obligation to require fields.
* It is better to be cognizant of this fact in our typings, and let the Snapshot conversions handle this.
*
* Fields that existing on the database type can only replace typings on the specific type, and not declare new typings.
* This is to prevent accidents related to adding/removing fields but not adding the correct conversions.
*
* This declaration allows a second type to defined overrides for how the data is stored within Firestore. For example, since by default
* this library choses to store dates as an ISO8601String, you can strictly specify that, and gain the type checking benefits. For other data types
* that are the same in the datastore as they are here, they are considered "any".
*
* The reason for this is that FirestoreModelData types are typically never used directly, execept for our snapshotConverterFunctions(),
* and using the built-in snapshot firestore field converters. Unless we have specified a strict new type that we expect in the data,
* most of the time we are unconcerned with the final type of our ExpectedFirestoreModelData.
*
* This is a more lose type that takes the above into account. You will only see typing information for fields of R that override the converted type T.
* If you find yourself needing full typings, extend ExpectedFirestoreModelData instead.
*
* Example:
*
* export interface MockItem {
* string: string;
* date: Date;
* }
*
* export type MockItemData = ExpectedFirestoreModelData<MockItem, {
* // string field is not defined directly, will be treated as any.
* date: string; // we want typescript typing help for this in our converters.
* }>;
*
*/
export type FirestoreModelData<T extends object, R extends object = object> = Partial<ExpectedFirestoreModelData<T, MaybeMap<R>>>;
import { FirestoreModelData, SnapshotConverterConfig, SnapshotConverterFromFunction, SnapshotConverterFunctions, SnapshotConverterToFunction } from './snapshot.type';

// MARK: Snapshots
export type SnapshotConverterConfigWithFields<T extends object, O extends object = FirestoreModelData<T>> = {
readonly fields: ModelFieldConversionsConfig<T, O>;
};

export type SnapshotConverterConfigWithConversions<T extends object, O extends object = FirestoreModelData<T>> = {
readonly fieldConversions: ModelFieldConversions<T, O>;
};

export type SnapshotConverterModifier<T extends object, O extends object = FirestoreModelData<T>> = PartialModelModifier<T, O>;

export type SnapshotConverterConfig<T extends object, O extends object = FirestoreModelData<T>> = (SnapshotConverterConfigWithFields<T, O> | SnapshotConverterConfigWithConversions<T, O>) & {
readonly modifiers?: ArrayOrValue<SnapshotConverterModifier<T, O>>;
};

export interface SnapshotConverterFunctions<T extends object, O extends object = FirestoreModelData<T>> extends FirestoreDataConverter<T, O> {
from: SnapshotConverterFromFunction<T, O>;
to: SnapshotConverterToFunction<T, O>;
}

export type SnapshotConverterFromFirestoreFunction<T extends object, O extends object = FirestoreModelData<T>> = (snapshot: DocumentSnapshot<O>, options?: SnapshotOptions) => T;
export type SnapshotConverterFromFunction<T extends object, O extends object = FirestoreModelData<T>> = ApplyMapFunctionWithOptions<DocumentSnapshot<O>, T, SnapshotOptions>;
export type SnapshotConverterToFunction<T extends object, O extends object = FirestoreModelData<T>> = ApplyMapFunctionWithOptions<T, O, SetOptions>;

export function snapshotConverterFunctions<T extends object, O extends object = FirestoreModelData<T>>(config: SnapshotConverterConfig<T, O>): SnapshotConverterFunctions<T, O> {
const conversions: ModelFieldConversions<T, O> = (config as SnapshotConverterConfigWithConversions<T, O>).fieldConversions ?? modelFieldConversions<T, O>((config as SnapshotConverterConfigWithFields<T, O>).fields);
const mapFunctions = makeModelMapFunctions<T, O>(conversions);
const { from: fromData, to: toData } = config.modifiers ? modifyModelMapFunctions({ mapFunctions, modifiers: config.modifiers }) : mapFunctions;
const conversions: ModelFieldConversions<T, O> = toModelFieldConversions<T, O>(config);
const baseMapFunctions = makeModelMapFunctions<T, O>(conversions);
const mapFunctions = config.modifiers ? modifyModelMapFunctions({ mapFunctions: baseMapFunctions, modifiers: config.modifiers }) : baseMapFunctions;
const { from: fromData, to: toData } = mapFunctions;

const from: SnapshotConverterFromFunction<T, O> = (input: DocumentSnapshot, target?: Maybe<Partial<T>>) => {
const data = input.data();
Expand Down Expand Up @@ -110,6 +40,7 @@ export function snapshotConverterFunctions<T extends object, O extends object =
return {
from,
to,
mapFunctions,
fromFirestore: (snapshot: DocumentSnapshot<O>, options?: SnapshotOptions) => from(snapshot, undefined, options),
toFirestore: (modelObject: WithFieldValue<T> | PartialWithFieldValue<T>, options?: SetOptions) => to(modelObject as T, undefined, options)
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ApplyMapFunctionWithOptions, ArrayOrValue, MaybeMap, ModelFieldConversionsConfigRef, ModelFieldConversionsRef, ModelMapFunctions, PartialModelModifier, TypedMappedModelData } from '@dereekb/util';
import { FirestoreDataConverter, DocumentSnapshot, SetOptions, SnapshotOptions } from '../types';

/**
* The default "empty" value in the Firestore.
*/
export const FIRESTORE_EMPTY_VALUE = null;

/**
* The expected firestore document data for a specific type.
*
* This declaration allows a second type to defined overrides for how the data is stored within Firestore. For example, since by default
* this library choses to store dates as an ISO8601String, you can strictly specify that, and gain the type checking benefits.
*/
export type ExpectedFirestoreModelData<T extends object, R extends object = object> = TypedMappedModelData<T, R>;

/**
* What is considered the typings for the true "stored" data.
*
* All items are marked as partial and Maybe. This is because by design the firestore has no schema and has no obligation to require fields.
* It is better to be cognizant of this fact in our typings, and let the Snapshot conversions handle this.
*
* Fields that existing on the database type can only replace typings on the specific type, and not declare new typings.
* This is to prevent accidents related to adding/removing fields but not adding the correct conversions.
*
* This declaration allows a second type to defined overrides for how the data is stored within Firestore. For example, since by default
* this library choses to store dates as an ISO8601String, you can strictly specify that, and gain the type checking benefits. For other data types
* that are the same in the datastore as they are here, they are considered "any".
*
* The reason for this is that FirestoreModelData types are typically never used directly, execept for our snapshotConverterFunctions(),
* and using the built-in snapshot firestore field converters. Unless we have specified a strict new type that we expect in the data,
* most of the time we are unconcerned with the final type of our ExpectedFirestoreModelData.
*
* This is a more lose type that takes the above into account. You will only see typing information for fields of R that override the converted type T.
* If you find yourself needing full typings, extend ExpectedFirestoreModelData instead.
*
* Example:
*
* export interface MockItem {
* string: string;
* date: Date;
* }
*
* export type MockItemData = ExpectedFirestoreModelData<MockItem, {
* // string field is not defined directly, will be treated as any.
* date: string; // we want typescript typing help for this in our converters.
* }>;
*
*/
export type FirestoreModelData<T extends object, R extends object = object> = Partial<ExpectedFirestoreModelData<T, MaybeMap<R>>>;

export type SnapshotConverterConfigWithFields<T extends object, O extends object = FirestoreModelData<T>> = ModelFieldConversionsConfigRef<T, O>;
export type SnapshotConverterConfigWithConversions<T extends object, O extends object = FirestoreModelData<T>> = ModelFieldConversionsRef<T, O>;

export type SnapshotConverterModifier<T extends object, O extends object = FirestoreModelData<T>> = PartialModelModifier<T, O>;

export type SnapshotConverterConfig<T extends object, O extends object = FirestoreModelData<T>> = (SnapshotConverterConfigWithFields<T, O> | SnapshotConverterConfigWithConversions<T, O>) & {
readonly modifiers?: ArrayOrValue<SnapshotConverterModifier<T, O>>;
};

export interface SnapshotConverterFunctions<T extends object, O extends object = FirestoreModelData<T>> extends FirestoreDataConverter<T, O> {
readonly from: SnapshotConverterFromFunction<T, O>;
readonly to: SnapshotConverterToFunction<T, O>;
readonly mapFunctions: ModelMapFunctions<T, O>;
}

export type SnapshotConverterFromFirestoreFunction<T extends object, O extends object = FirestoreModelData<T>> = (snapshot: DocumentSnapshot<O>, options?: SnapshotOptions) => T;
export type SnapshotConverterFromFunction<T extends object, O extends object = FirestoreModelData<T>> = ApplyMapFunctionWithOptions<DocumentSnapshot<O>, T, SnapshotOptions>;
export type SnapshotConverterToFunction<T extends object, O extends object = FirestoreModelData<T>> = ApplyMapFunctionWithOptions<T, O, SetOptions>;
21 changes: 20 additions & 1 deletion packages/util/src/lib/model/model.conversion.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { build } from './../value/build';
import { countPOJOKeys, KeyValueTypleValueFilter } from '../object';
import { countPOJOKeys, KeyValueTypleValueFilter, objectHasNoKeys } from '../object';
import { modelFieldMapFunction, makeModelMapFunctions, modelFieldConversions } from './model.conversion';
import { copyField } from './model.conversion.field';
import { modifyModelMapFunctions } from './model.modify';
Expand Down Expand Up @@ -80,6 +80,16 @@ describe('makeModelMapFunctions', () => {
expect(result.test).toBe(defaultTestValue);
});

it('should return an empty object if the input is null.', () => {
const result = mapFunctions.to(null);
expect(objectHasNoKeys(result)).toBe(true);
});

it('should return an empty object if the input is undefined.', () => {
const result = mapFunctions.to(undefined);
expect(objectHasNoKeys(result)).toBe(true);
});

// todo: add target
});

Expand All @@ -93,6 +103,15 @@ describe('makeModelMapFunctions', () => {
expect(result.test).toBe(defaultTestValue);
});

it('should return an empty object if the input is null.', () => {
const result = mapFunctions.from(null);
expect(objectHasNoKeys(result)).toBe(true);
});

it('should return an empty object if the input is undefined.', () => {
const result = mapFunctions.from(undefined);
expect(objectHasNoKeys(result)).toBe(true);
});
// todo: add target
});

Expand Down
Loading

0 comments on commit 3d6fbe1

Please sign in to comment.