All files / src/graphql prepare-document.ts

100% Statements 62/62
100% Branches 47/47
100% Functions 13/13
100% Lines 52/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155128x                               128x               128x 55x   128x 49x             128x                                 38x 38x   38x 64x 55x 55x 55x   45x 45x 20x 20x 19x   44x         44x     9x 8x 8x 6x 6x 5x         5x     1x     38x       12x     13x 12x   12x   12x           12x 12x 10x   7x       12x     13x       10x             12x         128x 128x           128x       30x 30x 30x 30x 30x 30x    
import {
  DirectiveNode,
  DocumentNode,
  Kind,
  OperationDefinitionNode,
  SelectionNode,
  SelectionSetNode,
  visit,
} from 'graphql';
import type { SchemaFieldIndex } from '../services/SchemaIntrospector';
 
/**
 * Client-side directive marking a field as non-essential: when the connected
 * instance's schema lacks it, the field is dropped instead of failing the whole
 * query. Never sent to GitLab.
 */
export const OPTIONAL_DIRECTIVE = 'optional';
 
export interface PreparedDocument {
  document: DocumentNode;
  /** Variables the prepared document still declares; others must not be sent. */
  variableNames: ReadonlySet<string>;
}
 
const isOptional = (directives?: readonly DirectiveNode[]): boolean =>
  directives?.some((d) => d.name.value === OPTIONAL_DIRECTIVE) ?? false;
 
const stripOptional = (directives?: readonly DirectiveNode[]): DirectiveNode[] | undefined =>
  directives?.filter((d) => d.name.value !== OPTIONAL_DIRECTIVE);
 
/**
 * Selection for a kept field (or operation) whose every sub-selection was
 * dropped: a composite type needs at least one field, and __typename exists on
 * every type.
 */
const typenameOnly = (set: SelectionSetNode): SelectionSetNode => ({
  ...set,
  selections: [{ kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' } }],
});
 
/**
 * Drop what the instance cannot answer. An inline fragment on a type the schema
 * does not declare can never match, so it always goes. A missing field goes only
 * when marked @optional; an essential missing field is left in place so GitLab
 * reports it and the caller can fall back or fail loudly. Returns undefined when
 * nothing selectable remains.
 */
function pruneSelectionSet(
  set: SelectionSetNode,
  typeName: string | undefined,
  index: SchemaFieldIndex | undefined,
): SelectionSetNode | undefined {
  const fields = typeName ? index?.get(typeName) : undefined;
  const selections: SelectionNode[] = [];
 
  for (const selection of set.selections) {
    if (selection.kind === Kind.FIELD) {
      const optional = isOptional(selection.directives);
      const field = fields?.get(selection.name.value);
      if (optional && fields && !field && selection.name.value !== '__typename') continue;
 
      let selectionSet = selection.selectionSet;
      if (selectionSet) {
        const pruned = pruneSelectionSet(selectionSet, field?.type, index);
        if (!pruned && optional) continue;
        selectionSet = pruned ?? typenameOnly(selectionSet);
      }
      selections.push({
        ...selection,
        directives: stripOptional(selection.directives),
        selectionSet,
      });
      continue;
    }
 
    if (selection.kind === Kind.INLINE_FRAGMENT) {
      const condition = selection.typeCondition?.name.value;
      if (condition && index && !index.has(condition)) continue;
      const pruned = pruneSelectionSet(selection.selectionSet, condition ?? typeName, index);
      if (!pruned) continue;
      selections.push({
        ...selection,
        directives: stripOptional(selection.directives),
        selectionSet: pruned,
      });
      continue;
    }
 
    selections.push(selection);
  }
 
  return selections.length > 0 ? { ...set, selections } : undefined;
}
 
function prepare(document: DocumentNode, index: SchemaFieldIndex | undefined): PreparedDocument {
  const pruned: DocumentNode = {
    ...document,
    definitions: document.definitions.map((definition) => {
      if (definition.kind !== Kind.OPERATION_DEFINITION) return definition;
      const root = definition.operation === 'mutation' ? 'Mutation' : 'Query';
      const selectionSet =
        pruneSelectionSet(definition.selectionSet, root, index) ??
        typenameOnly(definition.selectionSet);
      return { ...definition, selectionSet };
    }),
  };
 
  // Variables referenced only by pruned selections must not stay declared:
  // GitLab rejects a declared-but-unused variable.
  const used = new Set<string>();
  visit(pruned, {
    VariableDefinition: () => false,
    Variable: (node) => {
      used.add(node.name.value);
    },
  });
 
  const result: DocumentNode = {
    ...pruned,
    definitions: pruned.definitions.map((definition) =>
      definition.kind === Kind.OPERATION_DEFINITION
        ? ({
            ...definition,
            variableDefinitions: definition.variableDefinitions?.filter((v) =>
              used.has(v.variable.name.value),
            ),
          } satisfies OperationDefinitionNode)
        : definition,
    ),
  };
 
  return { document: result, variableNames: used };
}
 
// Prepared documents are pure functions of (document, schema); both are
// long-lived, so memoise per pair without pinning either in memory.
const NO_SCHEMA = {};
const cache = new WeakMap<DocumentNode, WeakMap<object, PreparedDocument>>();
 
/**
 * Adapt a document to the instance schema (see pruneSelectionSet) and strip the
 * client-only @optional directive. Without a schema index nothing is pruned.
 */
export function prepareDocument(
  document: DocumentNode,
  index: SchemaFieldIndex | undefined,
): PreparedDocument {
  const key = index ?? NO_SCHEMA;
  let perSchema = cache.get(document);
  if (!perSchema) cache.set(document, (perSchema = new WeakMap()));
  let prepared = perSchema.get(key);
  if (!prepared) perSchema.set(key, (prepared = prepare(document, index)));
  return prepared;
}