diff --git a/.changeset/project-computed-type-safety.md b/.changeset/project-computed-type-safety.md new file mode 100644 index 000000000..0b4384175 --- /dev/null +++ b/.changeset/project-computed-type-safety.md @@ -0,0 +1,5 @@ +--- +"effect-app": patch +--- + +Tighten projectComputed projection schema typing against encoded repo fields and computed output types. diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts index a7373e545..15912c9a5 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -55,10 +55,14 @@ type ExtractExclusiveness = T extends QueryTogether = T extends QueryTogether ? TFieldValuesRefined : never +type ProjectableField = K extends keyof From ? From[K] extends I ? I + : I extends From[K] ? I + : never + : never type ProjectableEncoded = I extends FieldValues ? { [K in keyof I]: K extends keyof ( I extends { readonly _tag: infer Tag } ? Extract : From - ) ? I[K] + ) ? ProjectableField : From, K> : never } : never @@ -122,74 +126,107 @@ export type ComputedProjectionMathExpression = readonly right: ComputedProjectionMathExpression } -export type ComputedProjectionExpression = - | { - readonly _tag: "relation-count" - readonly path: string - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-any" - readonly path: string - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-every" - readonly path: string - readonly operation: ComputedProjectionOperation - } - | { - readonly _tag: "relation-distinct-count" - readonly path: string - readonly field: string - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-sum" - readonly path: string - readonly field: string - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-sum-expr" - readonly path: string - readonly expression: ComputedProjectionMathExpression - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-sum-expr-by" - readonly path: string - readonly expression: ComputedProjectionMathExpression - readonly unit: string - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-sum-expr-normalized" - readonly path: string - readonly expression: ComputedProjectionMathExpression - readonly unit: string - readonly toBase: string - readonly factors: Readonly> - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-collect" - readonly path: string - readonly field: string - readonly distinct: boolean - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-collect-fields" - readonly path: string - readonly fields: readonly string[] - readonly distinct: boolean - readonly operation?: ComputedProjectionOperation - } - | { - readonly _tag: "relation-length" - readonly path: string +declare const ComputedProjectionExpressionTypeId: unique symbol + +type ComputedProjectionOutput = { + readonly [ComputedProjectionExpressionTypeId]?: Covariant +} + +export type ComputedProjectionExpression = + & ComputedProjectionOutput + & ( + | { + readonly _tag: "relation-count" + readonly path: string + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-any" + readonly path: string + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-every" + readonly path: string + readonly operation: ComputedProjectionOperation + } + | { + readonly _tag: "relation-distinct-count" + readonly path: string + readonly field: string + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-sum" + readonly path: string + readonly field: string + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-sum-expr" + readonly path: string + readonly expression: ComputedProjectionMathExpression + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-sum-expr-by" + readonly path: string + readonly expression: ComputedProjectionMathExpression + readonly unit: string + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-sum-expr-normalized" + readonly path: string + readonly expression: ComputedProjectionMathExpression + readonly unit: string + readonly toBase: string + readonly factors: Readonly> + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-collect" + readonly path: string + readonly field: string + readonly distinct: boolean + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-collect-fields" + readonly path: string + readonly fields: readonly string[] + readonly distinct: boolean + readonly operation?: ComputedProjectionOperation + } + | { + readonly _tag: "relation-length" + readonly path: string + } + ) + +type ComputedProjectionOutputOf = T extends ComputedProjectionExpression ? A : never + +type ComputedProjectionShape = { + readonly [K in keyof M]: ComputedProjectionOutputOf +} + +type ProjectableComputedEncoded = I extends FieldValues ? { + [K in keyof I]: K extends keyof M ? ComputedProjectionShape[K] + : K extends keyof ( + I extends { readonly _tag: infer Tag } ? Extract : From + ) ? ProjectableField< + I[K], + I extends { readonly _tag: infer Tag } ? Extract : From, + K + > + : never } + : never + +type KeysOfUnion = T extends T ? keyof T : never + +type NoExtraComputedKeys = Exclude> extends never ? unknown + : never /** * An expression that aggregates values across documents (for use with {@link aggregate}). @@ -629,11 +666,11 @@ export const relation = < right: ComputedProjectionMathExpression ): ComputedProjectionMathExpression => ({ _tag: "mul", left, right }) }, - length: (): ComputedProjectionExpression => ({ + length: (): ComputedProjectionExpression => ({ _tag: "relation-length", path: path as string }), - count: (operation?: ComputedProjectionOperation): ComputedProjectionExpression => + count: (operation?: ComputedProjectionOperation): ComputedProjectionExpression => operation ? { _tag: "relation-count", @@ -644,7 +681,7 @@ export const relation = < _tag: "relation-count", path: path as string }, - any: (operation?: ComputedProjectionOperation): ComputedProjectionExpression => + any: (operation?: ComputedProjectionOperation): ComputedProjectionExpression => operation ? { _tag: "relation-any", @@ -655,15 +692,15 @@ export const relation = < _tag: "relation-any", path: path as string }, - every: (operation: ComputedProjectionOperation): ComputedProjectionExpression => ({ + every: (operation: ComputedProjectionOperation): ComputedProjectionExpression => ({ _tag: "relation-every", path: path as string, operation }), - distinctCount: ( - field: FieldPath>, + distinctCount: >>( + field: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression => operation ? { _tag: "relation-distinct-count", @@ -676,10 +713,10 @@ export const relation = < path: path as string, field: field as string }, - sum: ( - field: FieldPath>, + sum: >>( + field: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression => operation ? { _tag: "relation-sum", @@ -695,7 +732,7 @@ export const relation = < sumExpr: ( expression: ComputedProjectionMathExpression, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression => operation ? { _tag: "relation-sum-expr", @@ -708,11 +745,13 @@ export const relation = < path: path as string, expression }, - sumExprBy: ( + sumExprBy: >>( expression: ComputedProjectionMathExpression, - options: { unit: FieldPath> }, + options: { unit: F }, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression< + ReadonlyArray<{ readonly unit: FieldPathValue, F>; readonly total: number }> + > => operation ? { _tag: "relation-sum-expr-by", @@ -735,7 +774,7 @@ export const relation = < factors: Readonly> }, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression => operation ? { _tag: "relation-sum-expr-normalized", @@ -754,10 +793,10 @@ export const relation = < toBase: options.toBase, factors: options.factors }, - collect: ( - field: FieldPath>, + collect: >>( + field: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression, F>>> => operation ? { _tag: "relation-collect", @@ -772,10 +811,10 @@ export const relation = < field: field as string, distinct: false }, - collectDistinct: ( - field: FieldPath>, + collectDistinct: >>( + field: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression, F>>> => operation ? { _tag: "relation-collect", @@ -790,10 +829,12 @@ export const relation = < field: field as string, distinct: true }, - collectFields: ( - fields: readonly FieldPath>[], + collectFields: >[]>( + fields: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression< + ReadonlyArray, F[number]>> + > => operation ? { _tag: "relation-collect-fields", @@ -808,10 +849,12 @@ export const relation = < fields: fields as readonly string[], distinct: false }, - collectDistinctFields: ( - fields: readonly FieldPath>[], + collectDistinctFields: >[]>( + fields: F, operation?: ComputedProjectionOperation - ): ComputedProjectionExpression => + ): ComputedProjectionExpression< + ReadonlyArray, F[number]>> + > => operation ? { _tag: "relation-collect-fields", @@ -865,13 +908,16 @@ const makeComputedHelpers = (): ComputedHelper export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, - I extends Record, + I extends FieldValues, + M extends ComputedProjectionMap, A = ExtractFieldValuesRefined, R = never, E extends boolean = ExtractExclusiveness >( - schema: S.Codec, I, R>, - build: (helpers: ComputedHelpers>) => ComputedProjectionMap, + schema: + & S.Codec, I, R> + & S.Codec, ProjectableComputedEncoded, M>, R>, + build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, mode: "collect" ): ( current: Q @@ -879,13 +925,16 @@ export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, - I extends Record, + I extends FieldValues, + M extends ComputedProjectionMap, A = ExtractFieldValuesRefined, R = never, E extends boolean = ExtractExclusiveness >( - schema: S.Codec, - build: (helpers: ComputedHelpers>) => ComputedProjectionMap, + schema: + & S.Codec + & S.Codec, M>, R>, + build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, mode?: "project" ): ( current: Q @@ -893,13 +942,16 @@ export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, - I extends Record, + I extends FieldValues, + M extends ComputedProjectionMap, A = ExtractFieldValuesRefined, R = never, E extends boolean = ExtractExclusiveness >( - schema: S.Codec, I, R>, - computedProjection: ComputedProjectionMap, + schema: + & S.Codec, I, R> + & S.Codec, ProjectableComputedEncoded, M>, R>, + computedProjection: M & NoExtraComputedKeys, mode: "collect" ): ( current: Q @@ -907,13 +959,16 @@ export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, - I extends Record, + I extends FieldValues, + M extends ComputedProjectionMap, A = ExtractFieldValuesRefined, R = never, E extends boolean = ExtractExclusiveness >( - schema: S.Codec, - computedProjection: ComputedProjectionMap, + schema: + & S.Codec + & S.Codec, M>, R>, + computedProjection: M & NoExtraComputedKeys, mode?: "project" ): ( current: Q diff --git a/packages/effect-app/src/Model/query/new-kid-interpreter.ts b/packages/effect-app/src/Model/query/new-kid-interpreter.ts index c0adc8870..9c3bbb824 100644 --- a/packages/effect-app/src/Model/query/new-kid-interpreter.ts +++ b/packages/effect-app/src/Model/query/new-kid-interpreter.ts @@ -357,6 +357,21 @@ const walkTransformation = (t: S.AST.AST): S.AST.AST => { return t } +const objectAsts = (ast: S.AST.AST): readonly S.AST.Objects[] => { + const t = walkTransformation(ast) + if (S.AST.isObjects(t)) { + return [t] + } + if (S.AST.isUnion(t)) { + return t.types.flatMap(objectAsts) + } + return [] +} + +const objectKeys = (ast: S.AST.AST): readonly string[] => [ + ...new Set(objectAsts(ast).flatMap((object) => object.propertySignatures.map((_) => _.name as string))) +] + export const toFilter = < TFieldValues extends FieldValues, A, @@ -399,9 +414,10 @@ export const toFilter = < // TODO: support more complex (nested) schemas? if (schema) { const t = walkTransformation(SchemaAST.toEncoded(schema.ast)) - if (S.AST.isObjects(t)) { - select = t.propertySignatures.map((_) => _.name as string) - for (const prop of t.propertySignatures) { + const objects = [...objectAsts(t)] + if (Array.isArrayNonEmpty(objects)) { + select = [...objectKeys(t)] + for (const prop of objects.flatMap((_) => _.propertySignatures)) { if (S.AST.isArrays(prop.type)) { // make sure we only select when there are actually type literals in the tuple... // otherwise we might be dealing with strings etc. @@ -411,8 +427,7 @@ export const toFilter = < subKeys: Array.flatMap( prop.type.rest, (x) => { - const t = walkTransformation(x) - return S.AST.isObjects(t) ? t.propertySignatures.map((y) => y.name as string) : [] + return objectKeys(x) } ) } @@ -443,10 +458,7 @@ export const toFilter = < return [] as string[] } const encoded = walkTransformation(SchemaAST.toEncoded(baseSchema.ast)) - if (!S.AST.isObjects(encoded)) { - return [] as string[] - } - const encodedKeys = encoded.propertySignatures.map((_) => _.name as string) + const encodedKeys = objectKeys(encoded) return schemaKeys.filter((key) => !encodedKeys.includes(key)) })() const missingComputedKeys = nonEncodedSchemaKeys.filter((key) => !(computed && key in computed)) diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index a1b803460..2e541ff69 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -629,6 +629,7 @@ it("projectComputed validates extra computed keys", () => { const query = make>().pipe( projectComputed( S.Struct({ id: S.String }), + // @ts-expect-error extra computed keys are rejected statically; keep runtime guard covered computed({ pickedCount: relation>("items").count() }) @@ -637,12 +638,116 @@ it("projectComputed validates extra computed keys", () => { expect(() => toFilter(query, baseSchema)).toThrowError("Computed projection keys must exist in projection schema") }) +it("projectComputed constrains projection schema to encoded repo fields and computed outputs", () => { + const baseSchema = S.Struct({ + id: S.String, + name: S.String, + items: S.Array(S.Struct({ articleId: S.String, weight: S.Number })) + }) + type Encoded = S.Codec.Encoded + + make().pipe( + projectComputed( + S.Struct({ + id: S.String, + itemCount: S.Number, + hasItems: S.Boolean, + articleIds: S.Array(S.String) + }), + ({ relation }) => ({ + itemCount: relation("items").count(), + hasItems: relation("items").any(), + articleIds: relation("items").collect("articleId") + }) + ) + ) + + make().pipe( + // @ts-expect-error missingField is neither an encoded repo field nor a computed projection + projectComputed(S.Struct({ missingField: S.String }), computed({})) + ) + + make().pipe( + projectComputed( + // @ts-expect-error repo field name is encoded as string + S.Struct({ name: S.Number }), + computed({}) + ) + ) + + make().pipe( + projectComputed( + // @ts-expect-error itemCount computes a number + S.Struct({ itemCount: S.String }), + computed({ itemCount: relation("items").count() }) + ) + ) + + make().pipe( + projectComputed( + S.Struct({ itemCount: S.Number }), + // @ts-expect-error extra computed keys must exist in the projection schema + computed({ + itemCount: relation("items").count(), + extraCount: relation("items").count() + }) + ) + ) +}) + +it("projectComputed supports union projection schemas", () => { + const baseSchema = S.Union([ + S.Struct({ + _tag: S.Literal("open"), + id: S.String, + items: S.Array(S.Struct({ articleId: S.String, weight: S.Number })) + }), + S.Struct({ + _tag: S.Literal("closed"), + id: S.String, + closedAt: S.String + }) + ]) + type Encoded = S.Codec.Encoded + const query = make().pipe( + projectComputed( + S.Union([ + S.Struct({ + _tag: S.Literal("open"), + id: S.String, + articleCount: S.Number, + weight: S.Number, + articleIds: S.Array(S.String) + }), + S.Struct({ + _tag: S.Literal("closed"), + id: S.String, + closedAt: S.String + }) + ]), + computed({ + articleCount: relation("items").count(), + weight: relation("items").sum("weight"), + articleIds: relation("items").collect("articleId") + }) + ) + ) + + const interpreted = toFilter(query, baseSchema) + expect(interpreted.computed && Object.keys(interpreted.computed).toSorted()).toEqual([ + "articleCount", + "articleIds", + "weight" + ]) +}) + it("projection schema with computed fields fails without computed map", () => { const baseSchema = S.Struct({ id: S.String, items: S.Array(S.Struct({ value: S.Number })) }) const query = make>().pipe( + // @ts-expect-error missing computed keys are rejected statically; keep runtime guard covered projectComputed(S.Struct({ pickedCount: S.NonNegativeInt }), computed({})) ) expect(() => toFilter(query, baseSchema)).toThrowError("Missing computed projections for schema keys") @@ -1764,6 +1869,7 @@ it("memFilter: rejects extra computed keys not in projection schema", () => { const q = make().pipe( projectComputed( S.Struct({ id: S.String }), + // @ts-expect-error extra computed keys are rejected statically; keep runtime guard covered computed({ bogus: relation("items").count(where("tag", "a")) })