Skip to content
Draft
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
37 changes: 37 additions & 0 deletions docs-gen/content/docs/tools/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2605,6 +2605,43 @@ The filtered schema will include:

**Note:** The query must be valid against the schema. Root fields in the query (e.g., `vehicle`) must exist in the `Query` type of the schema.

#### Picking Unreferenced Definitions

Filtering keeps only the definitions the selected fields reach, so a scalar nobody selected, or a directive that does not appear on the retained slice, is dropped. The `@pick` directive on the selection query keeps them anyway.

```graphql
query Selection
@pick(
enums: ["FuelType", "VehicleStatus"]
scalars: ["DateTime"]
directives: ["constraint"]
) {
vehicle {
id
model
}
}
```

Each argument accepts three forms:

- Absent: only referenced definitions are kept, so a query without the directive filters exactly as it did before.
- An empty list, such as `scalars: []`: every definition of that kind is kept.
- A list of names: those definitions are kept in addition to the referenced ones.

An enum is always kept whole. There is no way to select a subset of its values, so filtering never changes what an enum means. Use `@instanceTag(exclude: ...)` in the model to steer which instances an instance tag unfolds into.

Selecting a directive keeps its definition only. It does not apply the directive to any field or type.

A query that only picks definitions has no fields to name. Leave the selection set empty and s2dm reads it as selecting nothing, which is the usual case for a schema of units, enums or shared directives.

```graphql
query Selection
@pick(enums: ["Weekday", "MonthOfYear"], directives: []) {}
```

**Note:** `@pick` is defined by s2dm rather than by the model, and is removed from the query before the query is validated against the schema. A GraphQL tool that does not know about it reports an unknown directive for a selection query that uses it.

### Root Type Filtering

All export commands and the compose command support the `--root-type` flag to filter the schema to only a specific type and its transitive dependencies.
Expand Down
5 changes: 3 additions & 2 deletions playground/src/components/QueryEditorWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { QueryEditor, useEditorContext, useGraphiQL } from "@graphiql/react";
import { parse, validate } from "graphql";
import { validate } from "graphql";
import { useEffect, useRef } from "react";
import { parseSelectionQuery } from "@/utils/selectionQuery";

type QueryEditorWrapperProps = {
selectionQuery: string;
Expand Down Expand Up @@ -37,7 +38,7 @@ export function QueryEditorWrapper({
}

try {
const document = parse(query);
const document = parseSelectionQuery(query);
if (schema) {
const errors = validate(schema, document);
onValidationChange(errors.length > 0);
Expand Down
4 changes: 3 additions & 1 deletion playground/src/components/explore/ExplorerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "@/store/selection/selectionSlice";
import { downloadTextFile } from "@/utils/download";
import { getErrorMessage } from "@/utils/getErrorMessage";
import { withPickDirective } from "@/utils/selectionQuery";
import "@graphiql/react/style.css";
import "@/components/graphiql-theme.css";

Expand Down Expand Up @@ -76,7 +77,8 @@ export function ExplorerTab() {
const graphqlSchema = useMemo(() => {
if (!originalSchema?.trim()) return undefined;
try {
return buildSchema(originalSchema);
const schemaText = withPickDirective(originalSchema);
return buildSchema(schemaText);
} catch {
return undefined;
}
Expand Down
44 changes: 44 additions & 0 deletions playground/src/utils/selectionQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { DocumentNode } from "graphql";
import { parse } from "graphql";

// GraphQL rejects an empty selection set, so a selection query the server accepts does
// not parse here untouched.
const EMPTY_SELECTION_SET = /\{\s*\}\s*$/;
const NOTHING_SELECTED = "{ __typename }";

/**
* Parse a selection query, reading an empty selection set as selecting no fields.
*
* A query that only picks definitions has no fields to name. Such a query is read as
* selecting `__typename`, which every type carries and which names nothing in the model.
*/
export function parseSelectionQuery(text: string): DocumentNode {
try {
return parse(text);
} catch (error) {
const stripped = text.trimEnd();
const repaired = stripped.replace(EMPTY_SELECTION_SET, NOTHING_SELECTED);
if (repaired === stripped) {
throw error;
}
return parse(repaired);
}
}

// The model does not define @pick, so an editor validating against it alone underlines every
// selection query that uses one. Added for validation and completion only; never exported.
const PICK_DIRECTIVE_SDL =
"directive @pick(enums: [String!], scalars: [String!], directives: [String!]) on QUERY";

/**
* Return the schema text with the @pick definition, so an editor can validate queries using it.
*
* A model that already defines the directive is returned unchanged, since a duplicate
* definition would make the schema fail to build.
*/
export function withPickDirective(schemaText: string): string {
if (/^\s*directive\s+@pick\b/m.test(schemaText)) {
return schemaText;
}
return `${schemaText}\n\n${PICK_DIRECTIVE_SDL}\n`;
}
6 changes: 4 additions & 2 deletions src/s2dm/api/routes/query_validate.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""Query validate route - validate GraphQL query against schema."""

from fastapi import APIRouter
from graphql import parse, print_schema, validate
from graphql import print_schema, validate

from s2dm.api.config import COMMON_RESPONSES
from s2dm.api.errors import ResponseError, format_error_list
from s2dm.api.models.base import ApiResponse
from s2dm.api.models.query_validate import ValidateQueryRequest
from s2dm.api.services.response_service import execute_and_respond
from s2dm.api.services.schema_service import path_for_content, process_schema_input, validate_schema_or_raise
from s2dm.exporters.utils.pick import extract_and_validate_picks, parse_selection_query
from s2dm.exporters.utils.schema_loader import load_schema

router = APIRouter(responses=COMMON_RESPONSES)
Expand All @@ -27,7 +28,8 @@ def process_request() -> list[str]:
query_path = path_for_content(request.selection_query, "selection_query", ".graphql")
query_text = query_path.read_text(encoding="utf-8")

query_document = parse(query_text)
parsed_query = parse_selection_query(query_text)
query_document, _ = extract_and_validate_picks(schema, parsed_query)

validation_errors = validate(schema, query_document)

Expand Down
1 change: 1 addition & 0 deletions src/s2dm/constants/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Directive(str, Enum):
REFERENCE = "reference"
VSPEC = "vspec"
MODL = "modl"
PICK = "pick"


class BuiltInDirective(str, Enum):
Expand Down
7 changes: 5 additions & 2 deletions src/s2dm/deps/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Literal

import yaml
from graphql import DocumentNode, parse
from graphql import DocumentNode
from pydantic import ValidationError

from s2dm.deps import DEPENDENCY_LOCK_FILENAME, clean_resolved_dependencies, resolve_dependencies
Expand All @@ -26,6 +26,7 @@
from s2dm.deps.resolve.providers import RemoteIdentityProvider
from s2dm.deps.resolve.resolve import validate_cached_dependency
from s2dm.deps.resolve.warnings import WarningCollector
from s2dm.exporters.utils.pick import parse_selection_query
from s2dm.exporters.utils.schema_loader import build_schema_str_with_optional_source_map
from s2dm.utils.compose import SchemaDefinition, SharedDefinitionResolver
from s2dm.utils.file import temp_file_from_content, temp_files_from_contents
Expand Down Expand Up @@ -115,7 +116,9 @@ def resolve_schema_selection(schema_path: Path) -> DocumentNode | None:

resolved_schema_path = schema_path.resolve()
if dependency.selection is not None:
selection_by_schema_path[resolved_schema_path] = parse(dependency.selection.read_text(encoding="utf-8"))
selection_by_schema_path[resolved_schema_path] = parse_selection_query(
dependency.selection.read_text(encoding="utf-8")
)

try:
schema_content, _ = build_schema_str_with_optional_source_map(
Expand Down
Loading