Skip to content

Commit

Permalink
Give records a reference to the model manager which loaded them
Browse files Browse the repository at this point in the history
We have a few upcoming things that require records to be able to do stuff to themselves -- reload, select new fields from the backend, maybe `record.save` or similar, and I suspect more in the future. I still think they should act mostly as DTOs that don't have a lot of business logic on them, but if we want to be able to even know what model they are for, we need a reference to the thing that produced them! This passes along the model manager which instantiated a record to the record in both imperative API land and in React land.

In order to make this change work in React land, we need a new little bit of metadata exported from the generated client. Our React hooks get passed functions from the api client like `useAction(api.post.create)`, so we need to be able to hop back from the `create` function that we get by reference to the `api.post` object. The generated client will need to decorate the `create` function with a reference, which is not super hard, but means that we have a backwards compatibility issue. `@gadgetinc/react` can't assume that it is upgraded at the same time as the api client, so it can't assume it is working against a newly generated api client that has this metadata.

For this reason, the model manager property on `GadgetRecord` is optional, which reflects the way it will be used in the real world without necessarily having that metadata available. When we go to build things like `record.reload()`, we can make that fail at runtime with a message saying "regenerate your client to get this to work!", instead of just assuming it is present.
  • Loading branch information
airhorns committed Jun 29, 2023
1 parent 90458d0 commit 7ce2c6e
Show file tree
Hide file tree
Showing 19 changed files with 160 additions and 69 deletions.
15 changes: 15 additions & 0 deletions packages/api-client-core/spec/GadgetRecord.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AnyPublicModelManager } from "../src";
import { ChangeTracking, GadgetRecord } from "../src/GadgetRecord";
interface SampleBaseRecord {
id?: string;
Expand Down Expand Up @@ -38,6 +39,8 @@ const expectPersistedChanges = (record: GadgetRecord<SampleBaseRecord>, ...prope
return _expectChanges(record, ChangeTracking.SinceLastPersisted, ...properties);
};

const mockModelManager: AnyPublicModelManager = {} as any;

describe("GadgetRecord", () => {
let productBaseRecord: SampleBaseRecord;
beforeAll(() => {
Expand All @@ -48,6 +51,18 @@ describe("GadgetRecord", () => {
};
});

it("can be constructed with a base record and no model manager for backwards compatibility", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
expect(product.id).toEqual("123");
expect(product.name).toEqual("A cool product");
expect(product.modelManager).toEqual(null);
});

it("can be constructed with a base record and a model manager", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord, mockModelManager);
expect(product.modelManager).toEqual(mockModelManager);
});

it("should respond toJSON, which returns the inner __gadget.fields properties", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
expect(product.toJSON()).toEqual({
Expand Down
31 changes: 31 additions & 0 deletions packages/api-client-core/src/AnyModelManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { GadgetConnection } from "./GadgetConnection";
import type { GadgetRecord } from "./GadgetRecord";
import type { GadgetRecordList } from "./GadgetRecordList";
import type { InternalModelManager } from "./InternalModelManager";

/**
* The manager class for a given model that uses the Public API, like `api.post` or `api.user`
**/
export interface AnyPublicModelManager {
connection: GadgetConnection;
apiIdentifier?: string;
findOne(id: string, options: any): Promise<GadgetRecord<any>>;
maybeFindOne(id: string, options: any): Promise<GadgetRecord<any> | null>;
findMany(options: any): Promise<GadgetRecordList<any>>;
findFirst(options: any): Promise<GadgetRecord<any>>;
maybeFindFirst(options: any): Promise<GadgetRecord<any> | null>;
}

/**
* The manager class for a given single model that uses the Public API, like `api.session`
**/
export interface AnyPublicSingletonModelManager {
connection: GadgetConnection;
apiIdentifier?: string;
get(): Promise<GadgetRecord<any>>;
}

/**
* Any model manager, either public or internal
*/
export type AnyModelManager = AnyPublicModelManager | AnyPublicSingletonModelManager | InternalModelManager;
8 changes: 8 additions & 0 deletions packages/api-client-core/src/GadgetFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GadgetRecord, GadgetRecordList, LimitToKnownKeys, RecordShape, VariableOptions } from ".";
import type { AnyPublicModelManager, AnyPublicSingletonModelManager } from "./AnyModelManager";

export type AsyncRecord<T extends RecordShape> = Promise<GadgetRecord<T>>;
export type AsyncNullableRecord<T extends RecordShape> = Promise<GadgetRecord<T> | null>;
Expand All @@ -15,6 +16,7 @@ export interface FindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -28,6 +30,7 @@ export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT>
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -40,6 +43,7 @@ export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -52,6 +56,7 @@ export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -64,6 +69,7 @@ export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

interface ActionWithIdAndVariables<OptionsT, VariablesT> {
Expand Down Expand Up @@ -104,6 +110,7 @@ interface ActionFunctionMetadata<OptionsT, VariablesT, SelectionT, SchemaT, Defa
hasAmbiguousIdentifier?: boolean;
hasCreateOrUpdateEffect?: boolean;
paramOnlyVariables?: readonly string[];
modelManager?: AnyPublicModelManager;
}

export type ActionFunction<OptionsT, VariablesT, SelectionT, SchemaT, DefaultsT> = ActionFunctionMetadata<
Expand Down Expand Up @@ -141,6 +148,7 @@ export interface GetFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicSingletonModelManager;
}

export interface GlobalActionFunction<VariablesT> {
Expand Down
9 changes: 6 additions & 3 deletions packages/api-client-core/src/GadgetRecord.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import type { Jsonify } from "type-fest";
import type { AnyModelManager } from "./AnyModelManager";
import { toPrimitiveObject } from "./support";

export enum ChangeTracking {
Expand All @@ -23,7 +24,7 @@ export class GadgetRecordImplementation<Shape extends RecordShape> {

private empty = false;

constructor(data: Shape) {
constructor(data: Shape, readonly modelManager: AnyModelManager | null = null) {
this.__gadget.instantiatedFields = cloneDeep(data);
this.__gadget.persistedFields = cloneDeep(data);
Object.assign(this.__gadget.fields, data);
Expand Down Expand Up @@ -193,6 +194,8 @@ export class GadgetRecordImplementation<Shape extends RecordShape> {
*/

/** Instantiate a `GadgetRecord` with the attributes of your model. A `GadgetRecord` can be used to track changes to your model and persist those changes via Gadget actions. */
export const GadgetRecord: new <Shape extends RecordShape>(data: Shape) => GadgetRecordImplementation<Shape> & Shape =
GadgetRecordImplementation as any;
export const GadgetRecord: new <Shape extends RecordShape>(
data: Shape,
modelManager?: AnyModelManager
) => GadgetRecordImplementation<Shape> & Shape = GadgetRecordImplementation as any;
export type GadgetRecord<Shape extends RecordShape> = GadgetRecordImplementation<Shape> & Shape;
6 changes: 3 additions & 3 deletions packages/api-client-core/src/GadgetRecordList.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable no-throw-literal */
/* eslint-disable @typescript-eslint/require-await */
import type { Jsonify } from "type-fest";
import type { AnyPublicModelManager } from "./AnyModelManager";
import type { GadgetRecord, RecordShape } from "./GadgetRecord";
import type { InternalModelManager } from "./InternalModelManager";
import type { AnyModelManager } from "./ModelManager";
import type { PaginationOptions } from "./operationBuilders";
import { GadgetClientError, GadgetOperationError } from "./support";

Expand All @@ -14,12 +14,12 @@ type PaginationConfig = {

/** Represents a list of objects returned from the API. Facilitates iterating and paginating. */
export class GadgetRecordList<Shape extends RecordShape> extends Array<GadgetRecord<Shape>> {
modelManager!: AnyModelManager | InternalModelManager;
modelManager!: AnyPublicModelManager | InternalModelManager;
pagination!: PaginationConfig;

/** Internal method used to create a list. Should not be used by applications. */
static boot<Shape extends RecordShape>(
modelManager: AnyModelManager | InternalModelManager,
modelManager: AnyPublicModelManager | InternalModelManager,
records: GadgetRecord<Shape>[],
pagination: PaginationConfig
) {
Expand Down
14 changes: 7 additions & 7 deletions packages/api-client-core/src/InternalModelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export class InternalModelManager {
private readonly capitalizedApiIdentifier: string;

constructor(
private readonly apiIdentifier: string,
readonly apiIdentifier: string,
readonly connection: GadgetConnection,
readonly options?: { pluralApiIdentifier: string; hasAmbiguousIdentifiers?: boolean }
) {
Expand Down Expand Up @@ -371,7 +371,7 @@ export class InternalModelManager {
.toPromise();
const assertSuccess = throwOnEmptyData ? assertOperationSuccess : assertNullableOperationSuccess;
const result = assertSuccess(response, ["internal", this.apiIdentifier]);
return hydrateRecord(response, result);
return hydrateRecord(response, result, this);
}

/**
Expand Down Expand Up @@ -404,7 +404,7 @@ export class InternalModelManager {
const plan = internalFindManyQuery(this.apiIdentifier, options);
const response = await this.connection.currentClient.query(plan.query, plan.variables).toPromise();
const connection = assertNullableOperationSuccess(response, ["internal", `list${this.capitalizedApiIdentifier}`]);
const records = hydrateConnection(response, connection);
const records = hydrateConnection(response, connection, this);

return GadgetRecordList.boot(this, records, { options, pageInfo: connection.pageInfo });
}
Expand Down Expand Up @@ -433,7 +433,7 @@ export class InternalModelManager {
connection = assertOperationSuccess(response, ["internal", `list${this.capitalizedApiIdentifier}`], throwOnEmptyData);
}

const records = hydrateConnection(response, connection);
const records = hydrateConnection(response, connection, this);
const recordList = GadgetRecordList.boot(this, records, { options, pageInfo: connection.pageInfo });
return recordList[0];
}
Expand Down Expand Up @@ -472,7 +472,7 @@ export class InternalModelManager {
})
.toPromise();
const result = assertMutationSuccess(response, ["internal", `create${this.capitalizedApiIdentifier}`]);
return hydrateRecord(response, result[this.apiIdentifier]);
return hydrateRecord(response, result[this.apiIdentifier], this);
});
}

Expand Down Expand Up @@ -504,7 +504,7 @@ export class InternalModelManager {
})
.toPromise();
const result = assertMutationSuccess(response, ["internal", `bulkCreate${capitalizedPluralApiIdentifier}`]);
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier]);
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier], this);
});
}

Expand All @@ -531,7 +531,7 @@ export class InternalModelManager {
.toPromise();
const result = assertMutationSuccess(response, ["internal", `update${this.capitalizedApiIdentifier}`]);

return hydrateRecord(response, result[this.apiIdentifier]);
return hydrateRecord(response, result[this.apiIdentifier], this);
});
}

Expand Down
15 changes: 0 additions & 15 deletions packages/api-client-core/src/ModelManager.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/api-client-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./AnyClient";
export * from "./AnyModelManager";
export * from "./ClientOptions";
export * from "./DataHydrator";
export * from "./FieldSelection";
Expand All @@ -9,7 +10,6 @@ export * from "./GadgetRecordList";
export * from "./GadgetTransaction";
export * from "./InMemoryStorage";
export * from "./InternalModelManager";
export * from "./ModelManager";
export * from "./operationBuilders";
export * from "./operationRunners";
export * from "./support";
Expand Down
25 changes: 13 additions & 12 deletions packages/api-client-core/src/operationRunners.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { VariableOptions } from ".";
import { actionOperation, findManyOperation, findOneByFieldOperation, globalActionOperation } from ".";
import type { AnyPublicModelManager } from "./AnyModelManager";
import { AnyPublicSingletonModelManager } from "./AnyModelManager";
import type { FieldSelection } from "./FieldSelection";
import type { GadgetConnection } from "./GadgetConnection";
import type { GadgetRecord, RecordShape } from "./GadgetRecord";
import { GadgetRecordList } from "./GadgetRecordList";
import type { AnyModelManager } from "./ModelManager";
import type { PaginationOptions, SelectionOptions } from "./operationBuilders";
import { findOneOperation } from "./operationBuilders";
import {
Expand All @@ -19,7 +20,7 @@ import {
} from "./support";

export const findOneRunner = async <Shape extends RecordShape = any>(
modelManager: { connection: GadgetConnection },
modelManager: AnyPublicModelManager | AnyPublicSingletonModelManager,
operation: string,
id: string | undefined,
defaultSelection: FieldSelection,
Expand All @@ -31,11 +32,11 @@ export const findOneRunner = async <Shape extends RecordShape = any>(
const response = await modelManager.connection.currentClient.query(plan.query, plan.variables).toPromise();
const assertSuccess = throwOnEmptyData ? assertOperationSuccess : assertNullableOperationSuccess;
const record = assertSuccess(response, [operation]);
return hydrateRecord<Shape>(response, record);
return hydrateRecord<Shape>(response, record, modelManager);
};

export const findOneByFieldRunner = async <Shape extends RecordShape = any>(
modelManager: { connection: GadgetConnection },
modelManager: AnyPublicModelManager,
operation: string,
fieldName: string,
fieldValue: string,
Expand All @@ -46,7 +47,7 @@ export const findOneByFieldRunner = async <Shape extends RecordShape = any>(
const plan = findOneByFieldOperation(operation, fieldName, fieldValue, defaultSelection, modelApiIdentifier, options);
const response = await modelManager.connection.currentClient.query(plan.query, plan.variables).toPromise();
const connectionObject = assertOperationSuccess(response, [operation]);
const records = hydrateConnection<Shape>(response, connectionObject);
const records = hydrateConnection<Shape>(response, connectionObject, modelManager);

if (records.length > 1) {
throw getNonUniqueDataError(modelApiIdentifier, fieldName, fieldValue);
Expand All @@ -56,7 +57,7 @@ export const findOneByFieldRunner = async <Shape extends RecordShape = any>(
};

export const findManyRunner = async <Shape extends RecordShape = any>(
modelManager: AnyModelManager,
modelManager: AnyPublicModelManager,
operation: string,
defaultSelection: FieldSelection,
modelApiIdentifier: string,
Expand All @@ -76,13 +77,13 @@ export const findManyRunner = async <Shape extends RecordShape = any>(
connectionObject = assertOperationSuccess(response, [operation], throwOnEmptyData);
}

const records = hydrateConnection<Shape>(response, connectionObject);
const records = hydrateConnection<Shape>(response, connectionObject, modelManager);
return GadgetRecordList.boot<Shape>(modelManager, records, { options, pageInfo: connectionObject.pageInfo });
};

export interface ActionRunner {
<Shape extends RecordShape = any>(
modelManager: { connection: GadgetConnection },
modelManager: AnyPublicModelManager,
operation: string,
defaultSelection: FieldSelection | null,
modelApiIdentifier: string,
Expand All @@ -94,7 +95,7 @@ export interface ActionRunner {
): Promise<Shape extends void ? void : GadgetRecord<Shape>>;

<Shape extends RecordShape = any>(
modelManager: { connection: GadgetConnection },
modelManager: AnyPublicModelManager,
operation: string,
defaultSelection: FieldSelection | null,
modelApiIdentifier: string,
Expand All @@ -107,7 +108,7 @@ export interface ActionRunner {
}

export const actionRunner: ActionRunner = async <Shape extends RecordShape = any>(
modelManager: { connection: GadgetConnection },
modelManager: AnyPublicModelManager | AnyPublicSingletonModelManager,
operation: string,
defaultSelection: FieldSelection | null,
modelApiIdentifier: string,
Expand All @@ -133,9 +134,9 @@ export const actionRunner: ActionRunner = async <Shape extends RecordShape = any

// todo this does not support pagination params right now, we'll need to add it to bulk action Results
if (isBulkAction) {
return hydrateRecordArray<Shape>(response, mutationResult[modelSelectionField]);
return hydrateRecordArray<Shape>(response, mutationResult[modelSelectionField], modelManager);
} else {
return hydrateRecord<Shape>(response, mutationResult[modelSelectionField]);
return hydrateRecord<Shape>(response, mutationResult[modelSelectionField], modelManager);
}
};

Expand Down
Loading

0 comments on commit 7ce2c6e

Please sign in to comment.