Skip to content

Commit

Permalink
feat: adds AliasField to allowed schema fields (#9531)
Browse files Browse the repository at this point in the history
* initial spike

* fix schema-record ManagedObject writes

* add tests

* fix prettier
  • Loading branch information
runspired committed Sep 1, 2024
1 parent bd5d2ba commit e8fc3ea
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 140 deletions.
52 changes: 52 additions & 0 deletions packages/core-types/src/schema/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,57 @@ export type GenericField = {
options?: ObjectValue;
};

/**
* A field that can be used to alias one key to another
* key present in the cache version of the resource.
*
* Unlike DerivedField, an AliasField may write to its
* source when a record is in an editable mode.
*
* AliasFields may utilize a transform, specified by type,
* to pre/post process the field.
*
* An AliasField may also specify a `kind` via options.
* `kind` may be any other valid field kind other than
*
* - `@hash`
* - `@id`
* - `@local`
* - `derived`
*
* This allows an AliasField to rename any field in the cache.
*
* Alias fields are generally intended to be used to support migrating
* between different schemas, though there are times where they are useful
* as a form of advanced derivation when used with a transform. For instance,
* an AliasField could be used to expose both a string and a Date version of the
* same field, with both being capable of being written to.
*
* @typedoc
*/
export type AliasField = {
kind: 'alias';
name: string;
type: null; // should always be null

/**
* The field def for which this is an alias.
*
* @typedoc
*/
options:
| GenericField
| ObjectField
| SchemaObjectField
| ArrayField
| SchemaArrayField
| ResourceField
| CollectionField
| LegacyAttributeField
| LegacyBelongsToField
| LegacyHasManyField;
};

/**
* Represents a field whose value is the primary
* key of the resource.
Expand Down Expand Up @@ -787,6 +838,7 @@ export type LegacyHasManyField = {

export type FieldSchema =
| GenericField
| AliasField
| LocalField
| ObjectField
| SchemaObjectField
Expand Down
30 changes: 14 additions & 16 deletions packages/schema-record/src/-private/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,13 @@ export function computeArray(
}

export function computeObject(
store: Store,
schema: SchemaService,
cache: Cache,
record: SchemaRecord,
identifier: StableRecordIdentifier,
field: ObjectField | SchemaObjectField,
path: string[]
field: ObjectField,
path: string[],
editable: boolean
) {
const managedObjectMapForRecord = ManagedObjectMap.get(record);
let managedObject;
Expand All @@ -141,18 +141,16 @@ export function computeObject(
if (!rawValue) {
return null;
}
if (field.kind === 'object') {
if (field.type) {
const transform = schema.transformation(field);
rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object;
}
// for schema-object, this should likely be an embedded SchemaRecord now
managedObject = new ManagedObject(store, schema, cache, field, rawValue, identifier, path, record, false);
if (!managedObjectMapForRecord) {
ManagedObjectMap.set(record, new Map([[field, managedObject]]));
} else {
managedObjectMapForRecord.set(field, managedObject);
}
if (field.type) {
const transform = schema.transformation(field);
rawValue = transform.hydrate(rawValue as ObjectValue, field.options ?? null, record) as object;
}
managedObject = new ManagedObject(schema, cache, field, rawValue, identifier, path, record, editable);

if (!managedObjectMapForRecord) {
ManagedObjectMap.set(record, new Map([[field, managedObject]]));
} else {
managedObjectMapForRecord.set(field, managedObject);
}
}
return managedObject;
Expand All @@ -163,7 +161,7 @@ export function computeSchemaObject(
cache: Cache,
record: SchemaRecord,
identifier: StableRecordIdentifier,
field: ObjectField | SchemaObjectField,
field: SchemaObjectField,
path: string[],
legacy: boolean,
editable: boolean
Expand Down
156 changes: 44 additions & 112 deletions packages/schema-record/src/-private/managed-object.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import type Store from '@ember-data/store';
import type { Signal } from '@ember-data/tracking/-private';
import { addToTransaction, createSignal, subscribe } from '@ember-data/tracking/-private';
import { assert } from '@warp-drive/build-config/macros';
import type { StableRecordIdentifier } from '@warp-drive/core-types';
import type { Cache } from '@warp-drive/core-types/cache';
import type { ObjectValue, Value } from '@warp-drive/core-types/json/raw';
import { STRUCTURED } from '@warp-drive/core-types/request';
// import { STRUCTURED } from '@warp-drive/core-types/request';
import type { ObjectField, SchemaObjectField } from '@warp-drive/core-types/schema/fields';

import type { SchemaRecord } from '../record';
import type { SchemaService } from '../schema';
import { MUTATE, OBJECT_SIGNAL, SOURCE } from '../symbols';
import { Editable, EmbeddedPath, MUTATE, OBJECT_SIGNAL, Parent, SOURCE } from '../symbols';

export function notifyObject(obj: ManagedObject) {
addToTransaction(obj[OBJECT_SIGNAL]);
}

type ObjectSymbol = typeof OBJECT_SIGNAL | typeof Parent | typeof SOURCE | typeof Editable | typeof EmbeddedPath;
const ObjectSymbols = new Set<ObjectSymbol>([OBJECT_SIGNAL, Parent, SOURCE, Editable, EmbeddedPath]);

type KeyType = string | symbol | number;
const ignoredGlobalFields = new Set<string>(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]);
// const ignoredGlobalFields = new Set<string>(['setInterval', 'nodeType', 'nodeName', 'length', 'document', STRUCTURED]);

export interface ManagedObject {
[MUTATE]?(
target: unknown[],
Expand All @@ -28,183 +32,111 @@ export interface ManagedObject {
}

export class ManagedObject {
[SOURCE]: object;
declare identifier: StableRecordIdentifier;
declare path: string[];
declare owner: SchemaRecord;
declare [SOURCE]: object;
declare [Parent]: StableRecordIdentifier;
declare [EmbeddedPath]: string[];
declare [OBJECT_SIGNAL]: Signal;
declare [Editable]: boolean;

constructor(
store: Store,
schema: SchemaService,
cache: Cache,
field: ObjectField | SchemaObjectField,
data: object,
identifier: StableRecordIdentifier,
path: string[],
owner: SchemaRecord,
isSchemaObject: boolean
editable: boolean
) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this[SOURCE] = { ...data };
this[OBJECT_SIGNAL] = createSignal(this, 'length');
const _SIGNAL = this[OBJECT_SIGNAL];
// const boundFns = new Map<KeyType, ProxiedMethod>();
this.identifier = identifier;
this.path = path;
this.owner = owner;
const transaction = false;
this[Editable] = editable;
this[Parent] = identifier;
this[EmbeddedPath] = path;

const _SIGNAL = this[OBJECT_SIGNAL];
const proxy = new Proxy(this[SOURCE], {
ownKeys() {
if (isSchemaObject) {
const fields = schema.fields({ type: field.type! });
return Array.from(fields.keys());
}

return Object.keys(self[SOURCE]);
},

has(target: unknown, prop: string | number | symbol) {
if (isSchemaObject) {
const fields = schema.fields({ type: field.type! });
return fields.has(prop as string);
}

return prop in self[SOURCE];
},

getOwnPropertyDescriptor(target, prop) {
if (!isSchemaObject) {
return {
writable: false,
enumerable: true,
configurable: true,
};
}
const fields = schema.fields({ type: field.type! });
if (!fields.has(prop as string)) {
throw new Error(`No field named ${String(prop)} on ${field.type}`);
}
const schemaForField = fields.get(prop as string)!;
switch (schemaForField.kind) {
case 'derived':
return {
writable: false,
enumerable: true,
configurable: true,
};
case '@local':
case 'field':
case 'attribute':
case 'resource':
case 'belongsTo':
case 'hasMany':
case 'collection':
case 'schema-array':
case 'array':
case 'schema-object':
case 'object':
return {
writable: false, // IS_EDITABLE,
enumerable: true,
configurable: true,
};
}
return {
writable: editable,
enumerable: true,
configurable: true,
};
},

get<R extends typeof Proxy<object>>(target: object, prop: keyof R, receiver: R) {
if (prop === OBJECT_SIGNAL) {
return _SIGNAL;
if (ObjectSymbols.has(prop as ObjectSymbol)) {
return self[prop as keyof typeof target];
}
if (prop === 'identifier') {
return self.identifier;
}
if (prop === 'owner') {
return self.owner;

if (prop === Symbol.toPrimitive) {
return null;
}
if (prop === Symbol.toStringTag) {
return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`;
}
if (prop === 'constructor') {
return Object;
}

if (prop === 'toString') {
return function () {
return `ManagedObject<${identifier.type}:${identifier.id} (${identifier.lid})>`;
};
}

if (prop === 'toHTML') {
return function () {
return '<div>ManagedObject</div>';
};
}

if (_SIGNAL.shouldReset) {
_SIGNAL.t = false;
_SIGNAL.shouldReset = false;
let newData = cache.getAttr(self.identifier, self.path);
let newData = cache.getAttr(identifier, path);
if (newData && newData !== self[SOURCE]) {
if (!isSchemaObject && field.type) {
if (field.type) {
const transform = schema.transformation(field);
newData = transform.hydrate(newData as ObjectValue, field.options ?? null, self.owner) as ObjectValue;
newData = transform.hydrate(newData as ObjectValue, field.options ?? null, owner) as ObjectValue;
}
self[SOURCE] = { ...(newData as ObjectValue) }; // Add type assertion for newData
}
}

if (isSchemaObject) {
const fields = schema.fields({ type: field.type! });
// TODO: is there a better way to do this?
if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) {
throw new Error(`Field ${prop} does not exist on schema object ${field.type}`);
}
}

if (prop in self[SOURCE]) {
if (!transaction) {
subscribe(_SIGNAL);
}
subscribe(_SIGNAL);

return (self[SOURCE] as R)[prop];
}
return Reflect.get(target, prop, receiver) as R;
},

set(target, prop: KeyType, value, receiver) {
if (prop === 'identifier') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
self.identifier = value;
return true;
}
if (prop === 'owner') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
self.owner = value;
return true;
}
if (isSchemaObject) {
const fields = schema.fields({ type: field.type! });
if (typeof prop === 'string' && !ignoredGlobalFields.has(prop) && !fields.has(prop)) {
throw new Error(`Field ${prop} does not exist on schema object ${field.type}`);
}
}
assert(`Cannot set read-only property '${String(prop)}' on ManagedObject`, editable);
const reflect = Reflect.set(target, prop, value, receiver);
if (!reflect) {
return false;
}

if (reflect) {
if (isSchemaObject || !field.type) {
cache.setAttr(self.identifier, self.path, self[SOURCE] as Value);
_SIGNAL.shouldReset = true;
return true;
}

if (!field.type) {
cache.setAttr(identifier, path, self[SOURCE] as Value);
} else {
const transform = schema.transformation(field);
const val = transform.serialize(self[SOURCE], field.options ?? null, self.owner);
cache.setAttr(self.identifier, self.path, val);
_SIGNAL.shouldReset = true;
const val = transform.serialize(self[SOURCE], field.options ?? null, owner);
cache.setAttr(identifier, path, val);
}
return reflect;

_SIGNAL.shouldReset = true;
return true;
},
}) as ManagedObject;

Expand Down
Loading

0 comments on commit e8fc3ea

Please sign in to comment.