Skip to content

Commit fe1ee1c

Browse files
committed
Add typed FieldMask to sdk-core
Introduces FieldMask<T> in @databricks/sdk-core/wkt with a schema-validated `build` entry point and a wire-format `toString()`. FieldMaskSchema and FieldMaskSchemaField describe a message's FieldMask-reachable fields, including lazy `() => FieldMaskSchema` children so recursive and mutually-recursive proto messages can describe themselves without temporal-dead-zone or forward-reference pitfalls. `FieldMask.build` validates every camelCase path segment-by-segment, throws on unknown fields or descents past scalar leaves, translates to wire format, and stores the result privately. `toString()` joins the stored wire paths with commas. Construction is private; generated per-message factories (landing in a follow-up) are the only sanctioned entry point. Tests are table-driven (`it.each`) with two groups: valid paths that translate and serialize, and invalid paths that throw with a message-scoped error. Co-authored-by: Isaac
1 parent 4b26764 commit fe1ee1c

3 files changed

Lines changed: 194 additions & 165 deletions

File tree

packages/core/src/wkt/fieldmask.ts

Lines changed: 115 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,8 @@
1-
// True if any property of T is callable, indicating a class instance (e.g.
2-
// Temporal.Instant, Date) rather than a plain data interface.
3-
type HasMethods<T> = true extends {
4-
[K in keyof T]-?: NonNullable<T[K]> extends (...args: never[]) => unknown
5-
? true
6-
: never;
7-
}[keyof T]
8-
? true
9-
: false;
10-
11-
/**
12-
* Utility type that derives all valid dot-separated field paths from a
13-
* TypeScript interface. Provides compile-time path validation for
14-
* {@link FieldMask}.
15-
*
16-
* Recursion stops at arrays, index signatures (Record/Map), and class
17-
* instances with methods (e.g. Temporal.Instant, Date). These are treated
18-
* as leaf nodes.
19-
*
20-
* @example
21-
* ```ts
22-
* interface Cluster {
23-
* name: string;
24-
* config: {numWorkers: number; scaling: {min: number}};
25-
* }
26-
* // "name" | "config" | "config.numWorkers" | "config.scaling" | "config.scaling.min"
27-
* type ClusterPaths = FieldPaths<Cluster>;
28-
* ```
29-
*/
30-
export type FieldPaths<T, Prefix extends string = ''> = {
31-
[K in keyof T & string]: NonNullable<T[K]> extends unknown[]
32-
? `${Prefix}${K}` // Array field — leaf, do not recurse.
33-
: NonNullable<T[K]> extends Record<string, unknown>
34-
? string extends keyof NonNullable<T[K]>
35-
? `${Prefix}${K}` // Index signature (Record/Map) — leaf, do not recurse.
36-
: HasMethods<NonNullable<T[K]>> extends true
37-
? `${Prefix}${K}` // Class instance with methods — leaf, do not recurse.
38-
: `${Prefix}${K}` | FieldPaths<NonNullable<T[K]>, `${Prefix}${K}.`>
39-
: `${Prefix}${K}`;
40-
}[keyof T & string];
41-
421
// Remove duplicates and paths subsumed by a parent (e.g. "config" subsumes
432
// "config.numWorkers").
44-
function normalize<P extends string>(paths: P[]): P[] {
3+
function normalize(paths: string[]): string[] {
454
const unique = [...new Set(paths)].sort();
46-
const result: P[] = [];
5+
const result: string[] = [];
476
for (const path of unique) {
487
const isSubsumed = result.some(existing => path.startsWith(existing + '.'));
498
if (!isSubsumed) {
@@ -54,33 +13,125 @@ function normalize<P extends string>(paths: P[]): P[] {
5413
}
5514

5615
/**
57-
* A type-safe field mask implementing google.protobuf.FieldMask semantics.
58-
* Provides compile-time path validation via {@link FieldPaths}. Paths are
59-
* always normalized: duplicates and paths subsumed by a parent are removed.
16+
* One field entry in a {@link FieldMaskSchema}: its wire-format name and,
17+
* for message-typed fields, a lazy reference to the nested message's
18+
* schema. Array, map, enum, and scalar fields omit `children`.
19+
*/
20+
export interface FieldMaskSchemaField {
21+
readonly wire: string;
22+
readonly children?: () => FieldMaskSchema;
23+
}
24+
25+
/**
26+
* Structural description of one message's FieldMask-reachable fields.
27+
* Maps each camelCase field name to its wire-format name and, for
28+
* message-typed fields, a lazy `() => FieldMaskSchema` reference that
29+
* lets recursive and mutually-recursive messages describe themselves
30+
* without temporal-dead-zone pitfalls.
31+
*/
32+
export type FieldMaskSchema = Readonly<Record<string, FieldMaskSchemaField>>;
33+
34+
// Walk a dot-separated camelCase path against a schema, returning the
35+
// equivalent wire-format path. Returns `undefined` when any segment
36+
// fails: a name that isn't a field of the current message, or a
37+
// non-terminal segment that doesn't reference another message. Kept
38+
// module-private — callers go through `FieldMask.build`, which is the
39+
// only sanctioned construction path.
40+
function walkFieldMaskPath(
41+
schema: FieldMaskSchema,
42+
path: string
43+
): string | undefined {
44+
const segments = path.split('.');
45+
const wireSegments: string[] = [];
46+
let current: FieldMaskSchema = schema;
47+
for (let i = 0; i < segments.length; i++) {
48+
const seg = segments[i];
49+
// Existence check before lookup — `current[seg]` is typed as
50+
// FieldMaskSchemaField without noUncheckedIndexedAccess, so an
51+
// undefined check downstream would be flagged "unnecessary".
52+
if (!(seg in current)) return undefined;
53+
const field = current[seg];
54+
wireSegments.push(field.wire);
55+
if (i < segments.length - 1) {
56+
if (field.children === undefined) return undefined;
57+
current = field.children();
58+
}
59+
}
60+
return wireSegments.join('.');
61+
}
62+
63+
/**
64+
* A field mask implementing google.protobuf.FieldMask semantics.
65+
*
66+
* Every mask is tagged with the message type it targets (`T`) so that
67+
* generated client methods reject a mask built for the wrong message at
68+
* compile time. Users never construct a `FieldMask` directly: the
69+
* constructor is private and the only construction path is a generated
70+
* per-message factory (e.g. `alertFieldMask(...)`) that delegates to
71+
* `FieldMask.build`. Build validates every path against the target
72+
* message's schema and throws synchronously on an unknown path, so
73+
* typos fail at the call site rather than silently on the wire.
74+
*
75+
* The wire-format translation happens once, inside `build`, and the
76+
* resulting wire-format paths are stored on the instance. `toString()`
77+
* joins them with commas when the mask is serialized onto the wire.
6078
*
61-
* @example
62-
* ```ts
63-
* const mask = FieldMask.of<FieldPaths<Cluster>>(
64-
* 'displayName',
65-
* 'config.numWorkers'
66-
* );
67-
* ```
79+
* The camelCase paths the caller passed in are not retained. Treat the
80+
* mask as an opaque, immutable handle: construct with a factory, pass
81+
* to a client method, let the SDK serialize it. `paths` is kept
82+
* `private readonly` as a compile-time hint; it's not meant to be
83+
* accessed from outside the class.
6884
*/
69-
export class FieldMask<TPath extends string = string> {
70-
/** The list of field paths in this mask. */
71-
readonly paths: TPath[];
85+
export class FieldMask<T = unknown> {
86+
// Phantom marker: keeps `FieldMask<Alert>` and `FieldMask<Query>`
87+
// compile-time distinct under TypeScript's otherwise-structural
88+
// typing. Never set at runtime.
89+
declare private readonly _tag: T;
7290

73-
private constructor(paths: TPath[]) {
74-
this.paths = normalize(paths);
91+
// Stored post-translation, normalized wire-format paths. Marked
92+
// `private readonly` so the TS compiler rejects external access and
93+
// internal reassignment; callers should treat the mask as opaque and
94+
// interact through the factory and `toString()`.
95+
private readonly paths: string[];
96+
97+
private constructor(paths: string[]) {
98+
this.paths = paths;
7599
}
76100

77-
/** Create a field mask from one or more paths. */
78-
static of<P extends string>(...paths: P[]): FieldMask<P> {
79-
return new FieldMask(paths);
101+
/**
102+
* Build a FieldMask from camelCase paths against the target message's
103+
* schema. Validates every path by walking each segment through the
104+
* schema and throws `Unknown field path "<p>"` on the first segment
105+
* that names an unknown field or descends past a scalar leaf. On
106+
* success, the paths are normalized (dedup + subsumption), translated
107+
* to wire format, and stored on the instance.
108+
*
109+
* Reserved for generated per-message factories; user code should call
110+
* the factory (e.g. `alertFieldMask(...)`), which supplies the
111+
* schema before delegating here.
112+
*
113+
* @internal
114+
*/
115+
static build<T>(paths: string[], schema: FieldMaskSchema): FieldMask<T> {
116+
const normalized = normalize(paths);
117+
const wire: string[] = [];
118+
for (const p of normalized) {
119+
const w = walkFieldMaskPath(schema, p);
120+
if (w === undefined) {
121+
throw new Error(`Unknown field path "${p}"`);
122+
}
123+
wire.push(w);
124+
}
125+
return new FieldMask<T>(wire);
80126
}
81127

82-
/** Return a new mask with additional paths appended. */
83-
append(...paths: TPath[]): FieldMask<TPath> {
84-
return new FieldMask([...this.paths, ...paths]);
128+
/**
129+
* Serialize the mask to the wire-format string the server expects: a
130+
* comma-separated list of field paths in the message's wire naming
131+
* (proto snake_case for Databricks). The wire paths were computed at
132+
* construction time; this method just joins them.
133+
*/
134+
toString(): string {
135+
return this.paths.join(',');
85136
}
86137
}

packages/core/src/wkt/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export {FieldMask} from './fieldmask';
2-
export type {FieldPaths} from './fieldmask';
2+
export type {FieldMaskSchema, FieldMaskSchemaField} from './fieldmask';
33
export type {JsonValue, JsonObject} from './value';

0 commit comments

Comments
 (0)