1import * as Arr from "../../Array.ts"
2import * as InternalRecord from "../../internal/record.ts"
3import * as JsonSchema from "../../JsonSchema.ts"
4import * as Option from "../../Option.ts"
5import * as Predicate from "../../Predicate.ts"
6import * as Schema from "../../Schema.ts"
7import * as SchemaAST from "../../SchemaAST.ts"
8import * as SchemaTransformation from "../../SchemaTransformation.ts"
9import * as Tool from "../Tool.ts"
10
11const REST_PROPERTY_NAME = "__rest__"
12const TAIL_PROPERTY_PREFIX = "__tail_"
13
14const RECORD_DESCRIPTION =
15 "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object"
16
17const TUPLE_DESCRIPTION =
18 "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements"
19const TUPLE_TAIL_DESCRIPTION = `${TUPLE_DESCRIPTION}. Post-rest elements use '__tail_0__', '__tail_1__', and so on`
20
21/**
22 * @internal
23 */
24export function resolveReference($ref: string, definitions: JsonSchema.Definitions): JsonSchema.JsonSchema {
25 const key = JsonSchema.getReferenceKey($ref)
26 if (key === undefined) {
27 throw new Error(`Unsupported reference ${JSON.stringify($ref)}`)
28 }
29 if (!Object.hasOwn(definitions, key)) {
30 throw new Error(`Invalid reference ${JSON.stringify($ref)}`)
31 }
32 return definitions[key]
33}
34
35/**
36 * @internal
37 */
38export function resolveTopLevelReference(
39 document: JsonSchema.Document<"draft-2020-12">
40): JsonSchema.Document<"draft-2020-12"> {
41 if (typeof document.schema.$ref !== "string") return document
42 return {
43 ...document,
44 schema: resolveReference(document.schema.$ref, document.definitions)
45 }
46}
47
48/**
49 * @internal
50 */
51export function toCodec<T, E, RD, RE>(
52 schema: Schema.ConstraintCodec<T, E, RD, RE>
53): Schema.ConstraintCodec<T, unknown, RD, RE> {
54 const jsonCodec = Schema.toCodecJson(schema)
55 const encoded = SchemaAST.toEncoded(jsonCodec.ast)
56 const from = transform(encoded)
57 if (from === encoded) {
58 return jsonCodec.ast === schema.ast ? schema : jsonCodec
59 }
60 return Schema.make<typeof schema>(
61 SchemaAST.decodeTo(from, jsonCodec.ast, SchemaTransformation.passthrough())
62 )
63}
64
65function transform(root: SchemaAST.AST): SchemaAST.AST {
66 let cache: Map<SchemaAST.AST, SchemaAST.AST> | undefined
67
68 function recur(ast: SchemaAST.AST): SchemaAST.AST {
69 switch (ast._tag) {
70 case "Union": {
71 const types = SchemaAST.mapOrSame(ast.types, recur)
72 const checks = prepareChecks(ast.checks)
73 const options = ast.options?.mode === "oneOf" ? { ...ast.options, mode: "anyOf" as const } : ast.options
74 if (types === ast.types && checks === ast.checks && options === ast.options) return ast
75 return new SchemaAST.Union(
76 types,
77 options,
78 ast.annotations,
79 checks,
80 ast.encoding,
81 ast.context,
82 ast.encodingChecks
83 )
84 }
85 case "Arrays": {
86 if (ast.elements.length > 0 || ast.rest.length > 1) {
87 return tupleToObject(ast, recur)
88 }
89 const rest = SchemaAST.mapOrSame(ast.rest, recur)
90 const checks = prepareChecks(ast.checks)
91 if (rest === ast.rest && checks === ast.checks) return ast
92 return new SchemaAST.Arrays(
93 ast.isMutable,
94 ast.elements,
95 rest,
96 ast.annotations,
97 checks,
98 ast.encoding,
99 ast.context,
100 ast.encodingChecks
101 )
102 }
103 case "Objects": {
104 if (ast.indexSignatures.length === 1 && ast.propertySignatures.length === 0) {
105 const indexSignature = ast.indexSignatures[0]
106 if (Tool.isEmptyParamsRecord(indexSignature)) return ast
107 }
108 if (ast.indexSignatures.length > 0) {
109 return objectToEntries(ast, recur)
110 }
111
112 const propertySignatures = SchemaAST.mapOrSame(ast.propertySignatures, (propertySignature) => {
113 let type = recur(propertySignature.type)
114 if (SchemaAST.isOptional(propertySignature.type)) {
115 type = optionalToNullable(type)
116 }
117 return type === propertySignature.type
118 ? propertySignature
119 : new SchemaAST.PropertySignature(propertySignature.name, type)
120 })
121 const checks = prepareChecks(ast.checks)
122 if (
123 propertySignatures === ast.propertySignatures &&
124 checks === ast.checks
125 ) {
126 return ast
127 }
128 return new SchemaAST.Objects(
129 propertySignatures,
130 ast.indexSignatures,
131 ast.annotations,
132 checks,
133 ast.encoding,
134 ast.context,
135 ast.encodingChecks
136 )
137 }
138 case "Suspend": {
139 const cached = cache?.get(ast)
140 if (cached !== undefined) return cached
141 const out = new SchemaAST.Suspend(
142 () => recur(ast.thunk()),
143 ast.annotations,
144 undefined,
145 ast.encoding,
146 ast.context
147 )
148 if (cache === undefined) cache = new Map()
149 cache.set(ast, out)
150 return out
151 }
152 default: {
153 const checks = prepareChecks(ast.checks)
154 return checks === ast.checks ? ast : SchemaAST.replaceChecks(ast, checks)
155 }
156 }
157 }
158
159 return recur(root)
160}
161
162function tupleToObject(ast: SchemaAST.Arrays, recur: (ast: SchemaAST.AST) => SchemaAST.AST): SchemaAST.AST {
163 const propertySignatures = ast.elements.map((element, index) =>
164 new SchemaAST.PropertySignature(String(index), element)
165 )
166 if (ast.rest.length === 1) {
167 propertySignatures.push(
168 new SchemaAST.PropertySignature(REST_PROPERTY_NAME, new SchemaAST.Arrays(false, [], ast.rest))
169 )
170 } else if (ast.rest.length > 1) {
171 propertySignatures.push(
172 new SchemaAST.PropertySignature(REST_PROPERTY_NAME, new SchemaAST.Arrays(false, [], [ast.rest[0]]))
173 )
174 for (let index = 1; index < ast.rest.length; index++) {
175 propertySignatures.push(
176 new SchemaAST.PropertySignature(`${TAIL_PROPERTY_PREFIX}${index - 1}__`, ast.rest[index])
177 )
178 }
179 }
180 const from = recur(
181 new SchemaAST.Objects(
182 propertySignatures,
183 [],
184 structuralAnnotations(ast, ast.rest.length > 1 ? TUPLE_TAIL_DESCRIPTION : TUPLE_DESCRIPTION),
185 compilerChecks(ast.checks)
186 )
187 )
188 return SchemaAST.decodeTo(
189 from,
190 ast,
191 SchemaTransformation.transform({
192 decode: (object) => {
193 const tuple: Array<unknown> = []
194 for (let index = 0; index < ast.elements.length; index++) {
195 const key = String(index)
196 if (object[key] !== undefined) tuple.push(object[key])
197 }
198 if (REST_PROPERTY_NAME in object) {
199 const rest = object[REST_PROPERTY_NAME]
200 for (let index = 0; index < rest.length; index++) {
201 tuple.push(rest[index])
202 }
203 }
204 for (let index = 1; index < ast.rest.length; index++) {
205 tuple.push(object[`${TAIL_PROPERTY_PREFIX}${index - 1}__`])
206 }
207 return tuple
208 },
209 encode: (tuple) => {
210 const object: Record<string, unknown> = {}
211 for (let index = 0; index < ast.elements.length; index++) {
212 if (index < tuple.length) object[String(index)] = tuple[index]
213 }
214 if (ast.rest.length >= 1) {
215 const tailLength = ast.rest.length - 1
216 const restEnd = Math.max(ast.elements.length, tuple.length - tailLength)
217 object[REST_PROPERTY_NAME] = tuple.slice(ast.elements.length, restEnd)
218 for (let index = 0; index < tailLength; index++) {
219 object[`${TAIL_PROPERTY_PREFIX}${index}__`] = tuple[restEnd + index]
220 }
221 }
222 return object
223 }
224 })
225 )
226}
227
228function objectToEntries(
229 ast: SchemaAST.Objects,
230 recur: (ast: SchemaAST.AST) => SchemaAST.AST
231): SchemaAST.AST {
232 const checks = combineChecks(recordChecks(ast.checks), compilerChecks(ast.checks))
233 const key = unionOrSingle([
234 ...ast.propertySignatures.map((propertySignature) => new SchemaAST.Literal(propertySignature.name as string)),
235 ...ast.indexSignatures.map((indexSignature) => indexSignature.parameter)
236 ])
237 const value = unionOrSingle([
238 ...ast.propertySignatures.map((propertySignature) => propertySignature.type),
239 ...ast.indexSignatures.map((indexSignature) => indexSignature.type)
240 ])
241 const from = recur(
242 new SchemaAST.Arrays(
243 false,
244 [],
245 [new SchemaAST.Arrays(false, [key, value], [])],
246 structuralAnnotations(ast, RECORD_DESCRIPTION),
247 checks
248 )
249 )
250 return SchemaAST.decodeTo(
251 from,
252 ast,
253 SchemaTransformation.transform({
254 decode: Object.fromEntries,
255 encode: Object.entries
256 })
257 )
258}
259
260function unionOrSingle(types: ReadonlyArray<SchemaAST.AST>): SchemaAST.AST {
261 if (types.length === 1) return types[0]
262 const unique = Array.from(new Set(types))
263 return unique.length === 1 ? unique[0] : new SchemaAST.Union(unique)
264}
265
266function combineChecks(
267 left: SchemaAST.Checks | undefined,
268 right: SchemaAST.Checks | undefined
269): SchemaAST.Checks | undefined {
270 if (left === undefined) return right
271 if (right === undefined) return left
272 return [...left, ...right]
273}
274
275function compilerChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined {
276 if (checks === undefined) return undefined
277 const out = checks.flatMap(compilerCheck)
278 return Arr.isArrayNonEmpty(out) ? out : undefined
279}
280
281function compilerCheck(check: SchemaAST.Check<any>): Array<SchemaAST.Check<any>> {
282 // Structural rewrites change the value seen by provider-side checks. A
283 // no-op proxy lets the generic compiler call `toJsonSchema` with the real
284 // provider type and dependency schemas without running the original filter
285 // against the wrong runtime shape.
286 const annotations = compilerAnnotations(check.annotations)
287 if (check._tag === "Filter") {
288 return annotations === undefined ? [] : [new SchemaAST.Filter(() => undefined, annotations)]
289 }
290 const checks = check.checks.flatMap(compilerCheck)
291 if (Arr.isArrayNonEmpty(checks)) {
292 return [new SchemaAST.FilterGroup(checks, annotations)]
293 }
294 return annotations === undefined ? [] : [new SchemaAST.Filter(() => undefined, annotations)]
295}
296
297function compilerAnnotations(
298 annotations: Schema.Annotations.Filter | undefined
299): Schema.Annotations.Filter | undefined {
300 if (annotations?.toJsonSchema === undefined) return undefined
301 return {
302 representation: annotations.representation,
303 toJsonSchema: annotations.toJsonSchema
304 }
305}
306
307function optionalToNullable(type: SchemaAST.AST): SchemaAST.AST {
308 return SchemaAST.decodeTo(
309 new SchemaAST.Union([type, SchemaAST.null]),
310 SchemaAST.optionalKey(type),
311 SchemaTransformation.transformOptional({
312 decode: Option.filter(Predicate.isNotNull),
313 encode: Option.orElseSome(() => null)
314 })
315 )
316}
317
318function prepareChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined {
319 if (checks === undefined) return undefined
320 const out = SchemaAST.mapOrSame(checks, prepareCheck)
321 return out as SchemaAST.Checks
322}
323
324function prepareCheck(check: SchemaAST.Check<any>): SchemaAST.Check<any> {
325 switch (check._tag) {
326 case "Filter": {
327 const id = check.annotations?.representation?.id
328 return id === "effect/schema/isFinite" || id === "effect/schema/isInt"
329 ? check.annotate({ expected: undefined })
330 : check
331 }
332 case "FilterGroup": {
333 const checks = SchemaAST.mapOrSame(check.checks, prepareCheck)
334 return checks === check.checks
335 ? check
336 : new SchemaAST.FilterGroup(checks, check.annotations)
337 }
338 }
339}
340
341function recordChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined {
342 if (checks === undefined) return undefined
343 const out = checks.flatMap(recordCheck)
344 return Arr.isArrayNonEmpty(out) ? out : undefined
345}
346
347function recordCheck(check: SchemaAST.Check<any>): Array<SchemaAST.Check<any>> {
348 if (check._tag === "FilterGroup") return check.checks.flatMap(recordCheck)
349 const representation = check.annotations?.representation
350 const payload = representation?.payload
351 if (!isJsonObject(payload)) return []
352 // A decoded record with at least N properties necessarily came from at least
353 // N entries. Upper bounds are not transported because duplicate provider
354 // keys can collapse during `Object.fromEntries`, so `maxItems` could reject
355 // an input that the returned codec accepts.
356 switch (representation?.id) {
357 case "effect/schema/isMinProperties":
358 return typeof payload.minProperties === "number"
359 ? [withoutDescription(Schema.isMinLength(payload.minProperties))]
360 : []
361 case "effect/schema/isBetweenProperties":
362 return typeof payload.minimum === "number"
363 ? [withoutDescription(Schema.isMinLength(payload.minimum))]
364 : []
365 default:
366 return []
367 }
368}
369
370function isJsonObject(input: Schema.Json | undefined): input is Schema.JsonObject {
371 return typeof input === "object" && input !== null && !Array.isArray(input)
372}
373
374function withoutDescription(check: SchemaAST.Filter<any>): SchemaAST.Filter<any> {
375 return check.annotate({
376 description: undefined,
377 expected: undefined,
378 title: undefined
379 })
380}
381
382function structuralAnnotations(
383 ast: SchemaAST.AST,
384 structuralDescription: string
385): Schema.Annotations.Annotations {
386 const descriptions: Array<string> = []
387 appendAnnotationDescription(descriptions, ast.annotations)
388 if (ast.checks !== undefined) {
389 for (const check of ast.checks) appendCheckDescriptions(descriptions, check)
390 }
391 return {
392 description: descriptions.length === 0
393 ? structuralDescription
394 : `${structuralDescription}; ${descriptions.join(" and ")}`
395 }
396}
397
398function appendCheckDescriptions(descriptions: Array<string>, check: SchemaAST.Check<any>): void {
399 appendAnnotationDescription(descriptions, check.annotations)
400 if (check._tag === "FilterGroup") {
401 for (const child of check.checks) appendCheckDescriptions(descriptions, child)
402 }
403}
404
405function appendAnnotationDescription(
406 descriptions: Array<string>,
407 annotations: Schema.Annotations.Annotations | undefined
408): void {
409 const description = annotations?.description ?? annotations?.expected
410 if (typeof description === "string" && !descriptions.includes(description)) {
411 descriptions.push(description)
412 }
413}
414
415type JsonSchemaVisitor = (schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema
416
417/**
418 * @internal
419 */
420export function walkJsonSchema(
421 schema: JsonSchema.JsonSchema,
422 visitor: JsonSchemaVisitor
423): JsonSchema.JsonSchema {
424 const out: JsonSchema.JsonSchema = {}
425 for (const [key, value] of Object.entries(schema)) {
426 switch (key) {
427 case "properties":
428 case "patternProperties": {
429 if (isJsonSchema(value)) {
430 const properties: Record<string, unknown> = {}
431 for (const [name, property] of Object.entries(value)) {
432 InternalRecord.assignProperty(
433 properties,
434 name,
435 isJsonSchema(property) ? walkJsonSchema(property, visitor) : property
436 )
437 }
438 InternalRecord.assignProperty(out, key, properties)
439 } else {
440 InternalRecord.assignProperty(out, key, value)
441 }
442 break
443 }
444 case "additionalProperties":
445 case "items":
446 case "propertyNames": {
447 InternalRecord.assignProperty(out, key, isJsonSchema(value) ? walkJsonSchema(value, visitor) : value)
448 break
449 }
450 case "prefixItems":
451 case "allOf":
452 case "anyOf":
453 case "oneOf": {
454 InternalRecord.assignProperty(
455 out,
456 key,
457 Array.isArray(value)
458 ? value.map((member) => isJsonSchema(member) ? walkJsonSchema(member, visitor) : member)
459 : value
460 )
461 break
462 }
463 default:
464 InternalRecord.assignProperty(out, key, value)
465 break
466 }
467 }
468 return visitor(out)
469}
470
471/**
472 * @internal
473 */
474export function appendDescription(schema: JsonSchema.JsonSchema, description: string): void {
475 if (typeof schema.description === "string") {
476 const descriptions = schema.description.split(" and ")
477 if (!descriptions.includes(description)) schema.description += ` and ${description}`
478 } else {
479 schema.description = description
480 }
481}
482
483/**
484 * @internal
485 */
486export function isJsonSchema(input: unknown): input is JsonSchema.JsonSchema {
487 return typeof input === "object" && input !== null && !Array.isArray(input)
488}