Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 70 additions & 67 deletions packages/core/src/wkt/fieldmask.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,7 @@
// True if any property of T is callable, indicating a class instance (e.g.
// Temporal.Instant, Date) rather than a plain data interface.
type HasMethods<T> = true extends {
[K in keyof T]-?: NonNullable<T[K]> extends (...args: never[]) => unknown
? true
: never;
}[keyof T]
? true
: false;

/**
* Utility type that derives all valid dot-separated field paths from a
* TypeScript interface. Provides compile-time path validation for
* {@link FieldMask}.
*
* Recursion stops at arrays, index signatures (Record/Map), and class
* instances with methods (e.g. Temporal.Instant, Date). These are treated
* as leaf nodes.
*
* @example
* ```ts
* interface Cluster {
* name: string;
* config: {numWorkers: number; scaling: {min: number}};
* }
* // "name" | "config" | "config.numWorkers" | "config.scaling" | "config.scaling.min"
* type ClusterPaths = FieldPaths<Cluster>;
* ```
*/
export type FieldPaths<T, Prefix extends string = ''> = {
[K in keyof T & string]: NonNullable<T[K]> extends unknown[]
? `${Prefix}${K}` // Array field — leaf, do not recurse.
: NonNullable<T[K]> extends Record<string, unknown>
? string extends keyof NonNullable<T[K]>
? `${Prefix}${K}` // Index signature (Record/Map) — leaf, do not recurse.
: HasMethods<NonNullable<T[K]>> extends true
? `${Prefix}${K}` // Class instance with methods — leaf, do not recurse.
: `${Prefix}${K}` | FieldPaths<NonNullable<T[K]>, `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string];

// Remove duplicates and paths subsumed by a parent (e.g. "config" subsumes
// "config.numWorkers").
function normalize<P extends string>(paths: P[]): P[] {
// Remove duplicates and paths subsumed by a parent (e.g. "config" subsumes "config.numWorkers").
function normalize(paths: string[]): string[] {
const unique = [...new Set(paths)].sort();
const result: P[] = [];
const result: string[] = [];
for (const path of unique) {
const isSubsumed = result.some(existing => path.startsWith(existing + '.'));
if (!isSubsumed) {
Expand All @@ -54,33 +12,78 @@ function normalize<P extends string>(paths: P[]): P[] {
}

/**
* A type-safe field mask implementing google.protobuf.FieldMask semantics.
* Provides compile-time path validation via {@link FieldPaths}. Paths are
* always normalized: duplicates and paths subsumed by a parent are removed.
*
* @example
* ```ts
* const mask = FieldMask.of<FieldPaths<Cluster>>(
* 'displayName',
* 'config.numWorkers'
* );
* ```
* One field entry in a {@link FieldMaskSchema}: its wire-format name and, for message-typed fields, a lazy reference to the nested message's schema. Array, map, enum, and scalar fields omit `children`.
*/
export class FieldMask<TPath extends string = string> {
/** The list of field paths in this mask. */
readonly paths: TPath[];
export interface FieldMaskSchemaField {
readonly wire: string;
readonly children?: () => FieldMaskSchema;
}

private constructor(paths: TPath[]) {
this.paths = normalize(paths);
/**
* Structural description of one message's FieldMask-reachable fields. Maps each typescript field name to its wire-format name and, for message-typed fields, a lazy `() => FieldMaskSchema` reference that lets recursive and mutually-recursive messages describe themselves.
*/
export type FieldMaskSchema = Readonly<Record<string, FieldMaskSchemaField>>;

// Walk a dot-separated typescript field name path against a schema, returning the equivalent wire-format path. Returns `undefined` when any segment fails: a name that isn't a field of the current message, or a non-terminal segment that doesn't reference another message.
function walkFieldMaskPath(
schema: FieldMaskSchema,
path: string
): string | undefined {
const segments = path.split('.');
const wireSegments: string[] = [];
let current: FieldMaskSchema = schema;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
// Existence check before lookup: `current[seg]` is typed as FieldMaskSchemaField without noUncheckedIndexedAccess, so an undefined check downstream would be flagged "unnecessary".
if (!(seg in current)) return undefined;
const field = current[seg];
wireSegments.push(field.wire);
if (i < segments.length - 1) {
if (field.children === undefined) return undefined;
current = field.children();
}
}
return wireSegments.join('.');
}

/**
* A field mask implementing google.protobuf.FieldMask semantics.
*/
export class FieldMask<T = unknown> {
// Phantom marker: keeps `FieldMask<Alert>` and `FieldMask<Query>` compile-time distinct under TypeScript's otherwise-structural typing. Never set at runtime.
declare private readonly _tag: T;

/** Create a field mask from one or more paths. */
static of<P extends string>(...paths: P[]): FieldMask<P> {
return new FieldMask(paths);
// Stored post-translation, normalized wire-format paths.
private readonly paths: string[];

private constructor(paths: string[]) {
this.paths = paths;
}

/**
* Build a FieldMask from typescript field name paths against the target message's schema. Validates every path by walking each segment through the schema and throws Error when any segment fails.
*
* Reserved for generated per-message factories; user code should call the factory (e.g. `alertFieldMask(...)`), which supplies the schema before delegating here.
*
* @internal
*/
static build<T>(paths: string[], schema: FieldMaskSchema): FieldMask<T> {
const normalized = normalize(paths);
const wire: string[] = [];
for (const p of normalized) {
const w = walkFieldMaskPath(schema, p);
if (w === undefined) {
throw new Error(`Unknown field path "${p}"`);
}
wire.push(w);
}
return new FieldMask<T>(wire);
}

/** Return a new mask with additional paths appended. */
append(...paths: TPath[]): FieldMask<TPath> {
return new FieldMask([...this.paths, ...paths]);
/**
* Serialize the mask to the wire-format string.
*/
toString(): string {
return this.paths.join(',');
}
}
2 changes: 1 addition & 1 deletion packages/core/src/wkt/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export {FieldMask} from './fieldmask';
export type {FieldPaths} from './fieldmask';
export type {FieldMaskSchema, FieldMaskSchemaField} from './fieldmask';
export type {JsonValue, JsonObject} from './value';
187 changes: 87 additions & 100 deletions packages/core/tests/wkt/fieldmask.test.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,120 @@
import {describe, it, expect} from 'vitest';
import {FieldMask} from '../../src/wkt';
import type {FieldPaths} from '../../src/wkt';
import type {FieldMaskSchema} from '../../src/wkt';

// Simulates a class instance with methods (e.g. Temporal.Instant).
interface ClassLike {
epochMilliseconds: number;
toString(): string;
}
// Alert-like non-cyclic schema with nested Condition + Operand, used to drive FieldMask through its `@internal` build entry point.
const operandSchema: FieldMaskSchema = {
column: {wire: 'column'},
value: {wire: 'value'},
};
const conditionSchema: FieldMaskSchema = {
op: {wire: 'op'},
operand: {wire: 'operand', children: () => operandSchema},
};
const alertSchema: FieldMaskSchema = {
displayName: {wire: 'display_name'},
condition: {wire: 'condition', children: () => conditionSchema},
};

// Test interface for FieldPaths derivation.
interface Cluster {
name: string;
displayName: string;
state: string;
config: {
numWorkers: number;
scaling: {
minReplicas: number;
maxReplicas: number;
};
};
tags: string[];
labels: Record<string, string>;
createTime?: ClassLike;
}
// Self-referential schema used to verify cycle-safe construction.
const nodeSchema: FieldMaskSchema = {
label: {wire: 'label'},
child: {wire: 'child', children: () => nodeSchema},
};

// Verify FieldPaths derives correct paths at compile time.
type ClusterPaths = FieldPaths<Cluster>;
const _checkPaths: ClusterPaths[] = [
'name',
'displayName',
'state',
'config',
'config.numWorkers',
'config.scaling',
'config.scaling.minReplicas',
'config.scaling.maxReplicas',
'tags',
'labels',
'createTime', // Leaf — ClassLike has methods, so no recursion.
];
// Suppress unused variable warning.
void _checkPaths;

// Verify that ClassLike properties are NOT included as paths.
// If FieldPaths recursed into ClassLike, "createTime.epochMilliseconds" would
// be a valid path. This assignment must fail at compile time.
// @ts-expect-error - ClassLike is a leaf; its properties are not valid paths.
const _badPath: ClusterPaths = 'createTime.epochMilliseconds';
void _badPath;

describe('FieldMask', () => {
describe('of', () => {
const testCases: {name: string; input: string[]; want: string[]}[] = [
{name: 'single path', input: ['name'], want: ['name']},
describe('FieldMask.build', () => {
describe('valid paths translate and serialize', () => {
const cases: {
name: string;
schema: FieldMaskSchema;
input: string[];
want: string;
}[] = [
{
name: 'multiple paths',
input: ['displayName', 'name'],
want: ['displayName', 'name'],
name: 'flat path',
schema: alertSchema,
input: ['displayName'],
want: 'display_name',
},
{
name: 'deduplicates paths',
input: ['name', 'name', 'state'],
want: ['name', 'state'],
name: 'nested path',
schema: alertSchema,
input: ['condition.op'],
want: 'condition.op',
},
{
name: 'removes paths subsumed by a parent',
input: ['config.numWorkers', 'config', 'name'],
want: ['config', 'name'],
name: 'deeply nested path',
schema: alertSchema,
input: ['condition.operand.column'],
want: 'condition.operand.column',
},
{
name: 'removes deeply subsumed paths',
input: ['config.scaling.minReplicas', 'config.scaling', 'config'],
want: ['config'],
name: 'multiple paths joined in sorted order',
schema: alertSchema,
input: ['displayName', 'condition.op'],
want: 'condition.op,display_name',
},
{
name: 'does not subsume paths sharing a prefix without a dot boundary',
input: ['foo', 'foobar'],
want: ['foo', 'foobar'],
name: 'duplicates collapse before translation',
schema: alertSchema,
input: ['displayName', 'displayName'],
want: 'display_name',
},
{
name: 'children subsumed by a parent are dropped',
schema: alertSchema,
input: ['condition.op', 'condition'],
want: 'condition',
},
{
name: 'empty input serializes to empty string',
schema: alertSchema,
input: [],
want: '',
},
{
name: 'arbitrary depth through a self-cycle',
schema: nodeSchema,
input: ['child.child.child.label'],
want: 'child.child.child.label',
},
{name: 'empty mask', input: [], want: []},
];

it.each(testCases)('$name', ({input, want}) => {
const mask = FieldMask.of(...input);
expect(mask.paths).toStrictEqual(want);
it.each(cases)('$name', ({schema, input, want}) => {
const mask = FieldMask.build<unknown>(input, schema);
expect(mask.toString()).toBe(want);
});
});

describe('append', () => {
const testCases: {
describe('invalid paths throw', () => {
const cases: {
name: string;
initial: string[];
append: string[];
want: string[];
schema: FieldMaskSchema;
input: string[];
msg: string;
}[] = [
{
name: 'adds new paths',
initial: ['name'],
append: ['state'],
want: ['name', 'state'],
name: 'unknown top-level field',
schema: alertSchema,
input: ['bogus'],
msg: 'Unknown field path "bogus"',
},
{
name: 'deduplicates when appending existing paths',
initial: ['name', 'state'],
append: ['name'],
want: ['name', 'state'],
name: 'unknown nested field',
schema: alertSchema,
input: ['condition.bogus'],
msg: 'Unknown field path "condition.bogus"',
},
{
name: 'subsumes child when parent is appended',
initial: ['config.numWorkers'],
append: ['config'],
want: ['config'],
name: 'descent past a scalar leaf',
schema: alertSchema,
input: ['displayName.nope'],
msg: 'Unknown field path "displayName.nope"',
},
];

it.each(testCases)('$name', ({initial, append, want}) => {
const mask = FieldMask.of(...initial).append(...append);
expect(mask.paths).toStrictEqual(want);
});
});

describe('type safety with FieldPaths', () => {
it('works with derived FieldPaths type', () => {
const mask = FieldMask.of<FieldPaths<Cluster>>(
'displayName',
'config.numWorkers'
);
expect(mask.paths).toStrictEqual(['config.numWorkers', 'displayName']);
it.each(cases)('$name', ({schema, input, msg}) => {
expect(() => FieldMask.build<unknown>(input, schema)).toThrowError(msg);
});
});
});
Loading