Skip to content

Commit 755ecf0

Browse files
committed
feat: add minSize/maxSize bound nodes for File size validation
1 parent 5331b22 commit 755ecf0

9 files changed

Lines changed: 256 additions & 11 deletions

File tree

ark/schema/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export * from "./refinements/exactLength.ts"
1414
export * from "./refinements/kinds.ts"
1515
export * from "./refinements/max.ts"
1616
export * from "./refinements/maxLength.ts"
17+
export * from "./refinements/maxSize.ts"
1718
export * from "./refinements/min.ts"
1819
export * from "./refinements/minLength.ts"
20+
export * from "./refinements/minSize.ts"
1921
export * from "./refinements/pattern.ts"
2022
export * from "./refinements/range.ts"
2123
export * from "./roots/domain.ts"

ark/schema/intrinsic.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FileConstructor } from "@ark/util"
12
import { node, schemaScope } from "./scope.ts"
23
import { $ark } from "./shared/registry.ts"
34
import { arrayIndexSource } from "./structure/shared.ts"
@@ -18,7 +19,8 @@ const intrinsicBases = schemaScope(
1819
unknown: {},
1920
undefined: { unit: undefined },
2021
Array,
21-
Date
22+
Date,
23+
File: FileConstructor
2224
},
2325
{ prereducedAliases: true }
2426
).export()

ark/schema/refinements/kinds.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Before } from "./before.ts"
55
import { ExactLength } from "./exactLength.ts"
66
import { Max } from "./max.ts"
77
import { MaxLength } from "./maxLength.ts"
8+
import { MaxSize } from "./maxSize.ts"
89
import { Min } from "./min.ts"
910
import { MinLength } from "./minLength.ts"
11+
import { MinSize } from "./minSize.ts"
1012

1113
export interface BoundDeclarations {
1214
min: Min.Declaration
@@ -16,6 +18,8 @@ export interface BoundDeclarations {
1618
exactLength: ExactLength.Declaration
1719
after: After.Declaration
1820
before: Before.Declaration
21+
minSize: MinSize.Declaration
22+
maxSize: MaxSize.Declaration
1923
}
2024

2125
export interface BoundNodesByKind {
@@ -26,6 +30,8 @@ export interface BoundNodesByKind {
2630
exactLength: ExactLength.Node
2731
after: After.Node
2832
before: Before.Node
33+
minSize: MinSize.Node
34+
maxSize: MaxSize.Node
2935
}
3036

3137
export type BoundKind = keyof BoundDeclarations
@@ -43,7 +49,9 @@ export const boundImplementationsByKind: boundImplementationsByKind = {
4349
maxLength: MaxLength.implementation,
4450
exactLength: ExactLength.implementation,
4551
after: After.implementation,
46-
before: Before.implementation
52+
before: Before.implementation,
53+
minSize: MinSize.implementation,
54+
maxSize: MaxSize.implementation
4755
}
4856

4957
export const boundClassesByKind: Record<BoundKind, typeof BaseConstraint<any>> =
@@ -54,5 +62,7 @@ export const boundClassesByKind: Record<BoundKind, typeof BaseConstraint<any>> =
5462
maxLength: MaxLength.Node,
5563
exactLength: ExactLength.Node,
5664
after: After.Node,
57-
before: Before.Node
65+
before: Before.Node,
66+
minSize: MinSize.Node,
67+
maxSize: MaxSize.Node
5868
}

ark/schema/refinements/maxSize.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describeCollapsibleSize } from "@ark/util"
2+
import type { BaseRoot } from "../roots/root.ts"
3+
import type { BaseErrorContext, declareNode } from "../shared/declare.ts"
4+
import { Disjoint } from "../shared/disjoint.ts"
5+
import {
6+
implementNode,
7+
type nodeImplementationOf
8+
} from "../shared/implement.ts"
9+
import type { JsonSchema } from "../shared/jsonSchema.ts"
10+
import { $ark } from "../shared/registry.ts"
11+
import type { ToJsonSchema } from "../shared/toJsonSchema.ts"
12+
import type { TraverseAllows } from "../shared/traversal.ts"
13+
import {
14+
BaseRange,
15+
createSizeRuleParser,
16+
parseExclusiveKey,
17+
type BaseRangeInner,
18+
type UnknownExpandedRangeSchema
19+
} from "./range.ts"
20+
21+
export declare namespace MaxSize {
22+
export interface Inner extends BaseRangeInner {
23+
rule: number
24+
exclusive?: true
25+
}
26+
27+
export interface NormalizedSchema extends UnknownExpandedRangeSchema {
28+
rule: number
29+
}
30+
31+
export type Schema = NormalizedSchema | number
32+
33+
export interface ErrorContext extends BaseErrorContext<"maxSize">, Inner {}
34+
35+
export interface Declaration
36+
extends declareNode<{
37+
kind: "maxSize"
38+
schema: Schema
39+
normalizedSchema: NormalizedSchema
40+
inner: Inner
41+
prerequisite: File
42+
errorContext: ErrorContext
43+
}> {}
44+
45+
export type Node = MaxSizeNode
46+
}
47+
48+
const implementation: nodeImplementationOf<MaxSize.Declaration> =
49+
implementNode<MaxSize.Declaration>({
50+
kind: "maxSize",
51+
collapsibleKey: "rule",
52+
hasAssociatedError: true,
53+
keys: {
54+
rule: { parse: createSizeRuleParser("maxSize") },
55+
exclusive: parseExclusiveKey
56+
},
57+
normalize: schema =>
58+
typeof schema === "number" ? { rule: schema } : schema,
59+
defaults: {
60+
description: node => {
61+
if (node.rule === 0)
62+
return node.exclusive ? "negative size" : "non-positive size"
63+
const limit = describeCollapsibleSize(node.rule)
64+
return `${node.exclusive ? "less than" : "at most"} ${limit}`
65+
},
66+
actual: data => `${data.size} bytes`
67+
},
68+
intersections: {
69+
maxSize: (l, r) => (l.isStricterThan(r) ? l : r),
70+
minSize: (max, min) =>
71+
max.overlapsRange(min) ? null : Disjoint.init("range", max, min)
72+
}
73+
})
74+
75+
export class MaxSizeNode extends BaseRange<MaxSize.Declaration> {
76+
readonly impliedBasis: BaseRoot = $ark.intrinsic.File.internal
77+
78+
readonly expression: string = `${this.comparator} ${describeCollapsibleSize(this.rule)}`
79+
80+
traverseAllows: TraverseAllows<File> =
81+
this.exclusive ?
82+
data => data.size < this.rule
83+
: data => data.size <= this.rule
84+
85+
reduceJsonSchema(base: JsonSchema, ctx: ToJsonSchema.Context): JsonSchema {
86+
return ctx.fallback.size({ code: "size", base, maxSize: this.rule })
87+
}
88+
}
89+
90+
export const MaxSize = {
91+
implementation,
92+
Node: MaxSizeNode
93+
}

ark/schema/refinements/minSize.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describeCollapsibleSize } from "@ark/util"
2+
import type { IntersectionNode } from "../roots/intersection.ts"
3+
import type { BaseRoot } from "../roots/root.ts"
4+
import type { BaseErrorContext, declareNode } from "../shared/declare.ts"
5+
import {
6+
implementNode,
7+
type nodeImplementationOf
8+
} from "../shared/implement.ts"
9+
import type { JsonSchema } from "../shared/jsonSchema.ts"
10+
import { $ark } from "../shared/registry.ts"
11+
import type { ToJsonSchema } from "../shared/toJsonSchema.ts"
12+
import type { TraverseAllows } from "../shared/traversal.ts"
13+
import {
14+
BaseRange,
15+
createSizeRuleParser,
16+
parseExclusiveKey,
17+
type BaseRangeInner,
18+
type UnknownExpandedRangeSchema
19+
} from "./range.ts"
20+
21+
export declare namespace MinSize {
22+
export interface Inner extends BaseRangeInner {
23+
rule: number
24+
exclusive?: true
25+
}
26+
27+
export interface NormalizedSchema extends UnknownExpandedRangeSchema {
28+
rule: number
29+
}
30+
31+
export type Schema = NormalizedSchema | number
32+
33+
export interface ErrorContext extends BaseErrorContext<"minSize">, Inner {}
34+
35+
export interface Declaration
36+
extends declareNode<{
37+
kind: "minSize"
38+
schema: Schema
39+
normalizedSchema: NormalizedSchema
40+
inner: Inner
41+
prerequisite: File
42+
reducibleTo: "intersection"
43+
errorContext: ErrorContext
44+
}> {}
45+
46+
export type Node = MinSizeNode
47+
}
48+
49+
const implementation: nodeImplementationOf<MinSize.Declaration> =
50+
implementNode<MinSize.Declaration>({
51+
kind: "minSize",
52+
collapsibleKey: "rule",
53+
hasAssociatedError: true,
54+
keys: {
55+
rule: { parse: createSizeRuleParser("minSize") },
56+
exclusive: parseExclusiveKey
57+
},
58+
reduce: inner =>
59+
// a non-exclusive minimum size of zero is trivially satisfied
60+
inner.rule === 0 && !inner.exclusive ?
61+
($ark.intrinsic.unknown as IntersectionNode)
62+
: undefined,
63+
normalize: schema =>
64+
typeof schema === "number" ? { rule: schema } : schema,
65+
defaults: {
66+
description: node => {
67+
if (node.rule === 0)
68+
return node.exclusive ? "positive size" : "non-negative size"
69+
const limit = describeCollapsibleSize(node.rule)
70+
return `${node.exclusive ? "more than" : "at least"} ${limit}`
71+
},
72+
actual: data => `${data.size} bytes`
73+
},
74+
intersections: {
75+
minSize: (l, r) => (l.isStricterThan(r) ? l : r)
76+
}
77+
})
78+
79+
export class MinSizeNode extends BaseRange<MinSize.Declaration> {
80+
readonly impliedBasis: BaseRoot = $ark.intrinsic.File.internal
81+
82+
readonly expression: string = `${this.comparator} ${describeCollapsibleSize(this.rule)}`
83+
84+
traverseAllows: TraverseAllows<File> =
85+
this.exclusive ?
86+
data => data.size > this.rule
87+
: data => data.size >= this.rule
88+
89+
reduceJsonSchema(base: JsonSchema, ctx: ToJsonSchema.Context): JsonSchema {
90+
return ctx.fallback.size({ code: "size", base, minSize: this.rule })
91+
}
92+
}
93+
94+
export const MinSize = {
95+
implementation,
96+
Node: MinSizeNode
97+
}

ark/schema/refinements/range.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export abstract class BaseRange<
3939
readonly compiledActual: string =
4040
this.boundOperandKind === "value" ? `data`
4141
: this.boundOperandKind === "length" ? `data.length`
42+
: this.boundOperandKind === "size" ? `data.size`
4243
: `data.valueOf()`
4344
readonly comparator: RelativeComparator = compileComparator(
4445
this.kind,
@@ -166,19 +167,22 @@ const negatedComparators = {
166167
export const boundKindPairsByLower: BoundKindPairsByLower = {
167168
min: "max",
168169
minLength: "maxLength",
169-
after: "before"
170+
after: "before",
171+
minSize: "maxSize"
170172
}
171173

172174
type BoundKindPairsByLower = {
173175
min: "max"
174176
minLength: "maxLength"
175177
after: "before"
178+
minSize: "maxSize"
176179
}
177180

178181
type BoundKindPairsByUpper = {
179182
max: "min"
180183
maxLength: "minLength"
181184
before: "after"
185+
maxSize: "minSize"
182186
}
183187

184188
export type pairedRangeKind<kind extends RangeKind> =
@@ -198,12 +202,27 @@ export type NumericallyBoundable = string | number | array
198202
export type Boundable = NumericallyBoundable | Date
199203

200204
export const parseExclusiveKey: keySchemaDefinitions<
201-
Declaration<"min" | "max">
205+
Declaration<"min" | "max" | "minSize" | "maxSize">
202206
>["exclusive"] = {
203207
// omit key with value false since it is the default
204208
parse: (flag: boolean) => flag || undefined
205209
}
206210

211+
export type SizeBoundKind = "minSize" | "maxSize"
212+
213+
export const writeInvalidSizeBoundMessage = (
214+
kind: SizeBoundKind,
215+
limit: number
216+
): string => `${kind} bound must be a non-negative integer (was ${limit})`
217+
218+
export const createSizeRuleParser =
219+
(kind: SizeBoundKind) =>
220+
(limit: number): number => {
221+
if (!Number.isSafeInteger(limit) || limit < 0)
222+
throwParseError(writeInvalidSizeBoundMessage(kind, limit))
223+
return limit
224+
}
225+
207226
export const createLengthSchemaNormalizer =
208227
<kind extends "minLength" | "maxLength">(kind: kind) =>
209228
(schema: NodeSchema<kind>): NormalizedSchema<kind> => {
@@ -276,6 +295,8 @@ type OperandKindsByBoundKind = satisfy<
276295
maxLength: "length"
277296
after: "date"
278297
before: "date"
298+
minSize: "size"
299+
maxSize: "size"
279300
}
280301
>
281302

@@ -285,7 +306,9 @@ const operandKindsByBoundKind: OperandKindsByBoundKind = {
285306
minLength: "length",
286307
maxLength: "length",
287308
after: "date",
288-
before: "date"
309+
before: "date",
310+
minSize: "size",
311+
maxSize: "size"
289312
} as const
290313

291314
export const compileComparator = (
@@ -296,7 +319,7 @@ export const compileComparator = (
296319
exclusive ? "" : "="
297320
}` as const
298321

299-
export type BoundOperandKind = "value" | "length" | "date"
322+
export type BoundOperandKind = "value" | "length" | "date" | "size"
300323

301324
export type LengthBoundableData = string | array
302325

@@ -308,7 +331,7 @@ export const dateLimitToString = (limit: LimitSchemaValue): string =>
308331
export const writeUnboundableMessage = <root extends string>(
309332
root: root
310333
): writeUnboundableMessage<root> =>
311-
`Bounded expression ${root} must be exactly one of number, string, Array, or Date`
334+
`Bounded expression ${root} must be exactly one of number, string, Array, Date, or File`
312335

313336
export type writeUnboundableMessage<root extends string> =
314-
`Bounded expression ${root} must be exactly one of number, string, Array, or Date`
337+
`Bounded expression ${root} must be exactly one of number, string, Array, Date, or File`

ark/schema/roots/intersection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ const implementation: nodeImplementationOf<Intersection.Declaration> =
215215
predicate: {
216216
child: true,
217217
parse: constraintKeyParser("predicate")
218+
},
219+
minSize: {
220+
child: true,
221+
parse: constraintKeyParser("minSize")
222+
},
223+
maxSize: {
224+
child: true,
225+
parse: constraintKeyParser("maxSize")
218226
}
219227
},
220228
// leverage reduction logic from intersection and identity to ensure initial

ark/schema/shared/implement.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export const prestructuralKinds = [
5555
"maxLength",
5656
"minLength",
5757
"before",
58-
"after"
58+
"after",
59+
"maxSize",
60+
"minSize"
5961
] as const
6062

6163
export type PrestructuralKind = (typeof prestructuralKinds)[number]

0 commit comments

Comments
 (0)