Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/cli-engine/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prisma/cli-engine",
"version": "0.6.0",
"version": "0.6.1",
"description": "The execution engine of the unified Prisma CLI.",
"type": "module",
"exports": {
Expand Down
230 changes: 218 additions & 12 deletions packages/cli-engine/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,215 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return prototype === Object.prototype || prototype === null;
}

/** Plain objects and arrays copied; anything else, functions included, by reference. */
function copyPlainData(value: unknown): unknown {
/**
* Thrown when a section's schema itself is wrong, as opposed to the config
* file being wrong. It escapes validateSectionWithSchema rather than becoming
* a diagnostic, so the schema's author sees it instead of the user being told
* to fix a config file that is fine.
*/
export class ConfigSchemaError extends Error {}

/**
* What a compiled arktype schema says about one value: the object keys it
* declares (`props`), the positions of a tuple or list (`sequence`), or an
* index signature. A schema that says nothing about a value has no shape
* here, which is what tells the two walks below to leave that value alone.
*/
interface SchemaShape {
readonly props?: ReadonlyArray<{
readonly key: PropertyKey;
readonly value: SchemaNode;
}>;
readonly sequence?: {
readonly prefix?: readonly SchemaNode[];
readonly optionals?: readonly SchemaNode[];
readonly postfix?: readonly SchemaNode[];
readonly element?: SchemaNode;
};
readonly index?: readonly unknown[];
}

/** One node of a compiled arktype schema. */
interface SchemaNode {
readonly structure?: SchemaShape;
/** The alternatives of a union. */
readonly branches?: readonly SchemaNode[];
/** A node that transforms its input keeps what it accepts on its `in` side. */
readonly in?: SchemaNode;
/** Whether a pipe or a default applies at or under this node. */
readonly includesTransform?: boolean;
readonly allows?: (value: unknown) => boolean;
}

/**
* The node that applies to `value` here: the node itself, what a
* transforming node accepts, or the union alternative that matches. Nothing
* when no alternative matches, which validation is about to report.
*/
function nodeForValue(
node: SchemaNode | undefined,
value: unknown,
): SchemaNode | undefined {
if (node === undefined) return undefined;
if (node.branches !== undefined && node.branches.length > 1) {
const match = node.branches.find(
(branch) => branch.allows?.(value) === true,
);
return match === undefined ? undefined : nodeForValue(match, value);
}
if (
node.structure === undefined &&
node.in !== undefined &&
node.in !== node
) {
const inner = nodeForValue(node.in, value);
return inner?.structure === undefined ? node : inner;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return node;
}

/** The shape `node` gives this value, having already resolved the node. */
function shapeOf(node: SchemaNode | undefined): SchemaShape | undefined {
const shape = node?.structure;
if (shape?.index !== undefined && shape.index.length > 0) {
throw new ConfigSchemaError(
"@prisma/cli-engine: a config section schema cannot declare an index signature, because the keys it would match are not known ahead of the value; declare the keys, or check the value with a predicate",
);
}
return shape;
}

/**
* The node for one position of an array: a tuple's own position counting
* from either end, else the element every remaining entry shares.
*/
function nodeForPosition(
shape: SchemaShape,
index: number,
length: number,
): SchemaNode | undefined {
const sequence = shape.sequence;
if (sequence === undefined) return undefined;
const prefix = sequence.prefix ?? [];
if (index < prefix.length) return prefix[index];
const optionals = sequence.optionals ?? [];
if (index < prefix.length + optionals.length) {
return optionals[index - prefix.length];
}
const postfix = sequence.postfix ?? [];
const fromEnd = length - index;
if (fromEnd <= postfix.length) return postfix[postfix.length - fromEnd];
return sequence.element;
}

/**
* Copies `value` wherever the schema describes its shape, and no further.
* arktype applies a default by assigning to the object that holds it, so an
* object the config file froze has to be copied first. Anything the schema
* only checks, never describes, is passed through untouched.
*/
function copyWhereDescribed(
value: unknown,
node: SchemaNode | undefined,
): unknown {
const shape = shapeOf(nodeForValue(node, value));
// No alternative of a union matched: the value is about to fail
// validation, and copying it one level keeps a frozen object from turning
// that failure into a write to a read-only property.
const unmatchedUnion =
shape === undefined &&
node?.branches !== undefined &&
node.branches.length > 1;
if (shape === undefined && !unmatchedUnion) return value;
if (Array.isArray(value)) {
return value.map(copyPlainData);
return value.map((entry, index) =>
shape === undefined
? entry
: copyWhereDescribed(
entry,
nodeForPosition(shape, index, value.length),
),
);
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, copyPlainData(entry)]),
if (!isPlainObject(value)) return value;
const declared = new Map(shape?.props?.map((prop) => [prop.key, prop.value]));
return Object.fromEntries(
Reflect.ownKeys(value).map((key) => [
key,
copyWhereDescribed(
(value as Record<PropertyKey, unknown>)[key],
declared.get(key),
),
]),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/**
* Whether `output` is arktype's rebuild of `input`, rather than a different
* value a pipe produced in its place. A rebuild carries the same own keys;
* a replacement is a different object. Asking this per value is what keeps
* the walk below from undoing a pipe that returns an object of its own,
* without having to tell arktype's own rebuild apart from a pipe at the
* node above (in a compiled schema they look the same).
*/
function isRebuildOf(input: unknown, output: unknown): boolean {
if (typeof input !== "object" || input === null) return false;
if (typeof output !== "object" || output === null) return false;
if (Array.isArray(input) !== Array.isArray(output)) return false;
const inputKeys = Reflect.ownKeys(input);
const outputKeys = new Set(Reflect.ownKeys(output));
return (
inputKeys.length === outputKeys.size &&
inputKeys.every((key) => outputKeys.has(key))
);
}

/**
* arktype rebuilds an object whenever a default or a pipe applies anywhere
* inside it, and the rebuild clones every property, including values the
* schema only checked. Such a value is something the config file built at
* runtime: a function closing over module state, a class instance whose
* methods need their own `this`, a table of codecs. A clone of it is not it.
* So this walk puts the config file's own value back wherever the schema
* described no shape and the result is a rebuild of it, which is why a
* section schema checks such values with a predicate instead of describing
* them. A value the schema transformed on purpose keeps what the transform
* produced: a pipe at the value itself, and a pipe further up that returned
* a different object rather than a rebuild of this one.
*/
function putBackOriginalValues(
input: unknown,
output: unknown,
node: SchemaNode | undefined,
): unknown {
const applicable = nodeForValue(node, output);
const shape = shapeOf(applicable);
if (shape === undefined) {
if (applicable?.includesTransform === true) return output;
return isRebuildOf(input, output) ? input : output;
}
if (Array.isArray(output)) {
if (!Array.isArray(input)) return output;
return output.map((entry, index) =>
putBackOriginalValues(
input[index],
entry,
nodeForPosition(shape, index, output.length),
),
);
}
return value;
if (!isPlainObject(output) || !isPlainObject(input)) return output;
const declared = new Map(shape.props?.map((prop) => [prop.key, prop.value]));
return Object.fromEntries(
Reflect.ownKeys(output).map((key) => [
key,
putBackOriginalValues(
(input as Record<PropertyKey, unknown>)[key],
(output as Record<PropertyKey, unknown>)[key],
declared.get(key),
),
]),
);
}

function fieldDiagnostic(
Expand Down Expand Up @@ -161,24 +359,32 @@ export function validateSectionWithSchema<S extends ConfigSchema>(
const previous = current;
current = { name, provenance };
try {
// arktype applies defaults and morphs onto the objects it is handed, and
// the merged section value arrives frozen, so it validates a copy.
const out: unknown = schema(raw === undefined ? {} : copyPlainData(raw));
if (out instanceof type.errors) {
// arktype applies a default by assigning to the object that holds it,
// and the merged section arrives frozen, so the described shape is
// copied first; values the schema only checks keep their identity.
const node = schema.internal as unknown as SchemaNode;
const validated: unknown = schema(
raw === undefined ? {} : copyWhereDescribed(raw, node),
);
if (validated instanceof type.errors) {
return {
ok: false,
diagnostics: [...out].map((error) =>
diagnostics: [...validated].map((error) =>
fieldDiagnostic(name, error, provenance),
),
};
}
const out = putBackOriginalValues(raw, validated, node);
const nearest = provenance.files[0];
const value =
isPlainObject(out) && nearest !== undefined
? Object.freeze({ ...out, baseDir: dirname(nearest) })
: out;
return { ok: true, value: value as ConfigSchemaValue<S>, diagnostics: [] };
} catch (cause) {
// A schema that cannot be walked is its author's bug, not the user's
// config, so it is never turned into a diagnostic about their file.
if (cause instanceof ConfigSchemaError) throw cause;
// A getter that throws when arktype reads it, or a morph that throws:
// config-file content, reported as such rather than as a bug.
return {
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/src/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {
} from "../config-merge";
export {
type ConfigSchema,
ConfigSchemaError,
type ConfigSchemaValue,
configSchema,
validateSectionWithSchema,
Expand Down
Loading
Loading