diff --git a/modules/core/src/main/scala/minimizer.scala b/modules/core/src/main/scala/minimizer.scala index 65c4411c..41174515 100644 --- a/modules/core/src/main/scala/minimizer.scala +++ b/modules/core/src/main/scala/minimizer.scala @@ -35,7 +35,7 @@ object QueryMinimizer { def minimizeDocument(doc: Document): String = { import OperationDefinition._ import OperationType._ - import SchemaRenderer.renderDescription + import SchemaRenderer.{renderDescription, renderString} import Selection._ import Value._ @@ -141,7 +141,7 @@ object QueryMinimizer { case Variable(name) => s"$$${name.value}" case IntValue(value) => value.toString case FloatValue(value) => value.toString - case StringValue(value) => s""""$value"""" + case StringValue(value) => renderString(value) case BooleanValue(value) => value.toString case NullValue => "null" case EnumValue(name) => name.value diff --git a/modules/core/src/main/scala/query.scala b/modules/core/src/main/scala/query.scala index 126ae9d6..e38d96cb 100644 --- a/modules/core/src/main/scala/query.scala +++ b/modules/core/src/main/scala/query.scala @@ -270,7 +270,7 @@ object Query { } case class Binding(name: String, value: Value) { - def render: String = s"$name: $value" + def render: String = s"$name: ${SchemaRenderer.renderValue(value)}" } type UntypedVarDefs = List[UntypedVarDef] diff --git a/modules/core/src/main/scala/schema.scala b/modules/core/src/main/scala/schema.scala index 3235d0cf..9c03f168 100644 --- a/modules/core/src/main/scala/schema.scala +++ b/modules/core/src/main/scala/schema.scala @@ -2450,9 +2450,7 @@ object SchemaRenderer { val args = if (args0.isEmpty) "" else - args0 - .map { case Binding(nme, v) => s"$nme: ${renderValue(v)}" } - .mkString("(", ", ", ")") + args0.map(_.render).mkString("(", ", ", ")") s"@$name$args" } @@ -2580,15 +2578,38 @@ object SchemaRenderer { def renderValue(value: Value): String = value match { case IntValue(i) => i.toString case FloatValue(f) => f.toString - case StringValue(s) => s""""$s"""" + case StringValue(s) => renderString(s) case BooleanValue(b) => b.toString - case IDValue(i) => s""""$i"""" + case IDValue(i) => renderString(i) case EnumValue(e) => e case ListValue(elems) => elems.map(renderValue).mkString("[", ", ", "]") case ObjectValue(fields) => fields - .map { case (name, value) => s"$name : ${renderValue(value)}" } + .map { case (name, value) => s"$name: ${renderValue(value)}" } .mkString("{", ", ", "}") - case _ => "null" + case VariableRef(name) => s"$$$name" + case NullValue => "null" + case AbsentValue => "null" + } + + /** + * Renders a string as a GraphQL quoted string + */ + def renderString(str: String): String = { + val sb = new StringBuilder(str.length + 2) + sb.append('"') + str.foreach { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\b' => sb.append("\\b") + case '\f' => sb.append("\\f") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case c if c.isControl => sb.append(f"\\u${c.toInt}%04x") + case c => sb.append(c) + } + sb.append('"') + sb.toString } } diff --git a/modules/core/src/test/scala/minimizer/MinimizerSuite.scala b/modules/core/src/test/scala/minimizer/MinimizerSuite.scala index 327072be..3915f206 100644 --- a/modules/core/src/test/scala/minimizer/MinimizerSuite.scala +++ b/modules/core/src/test/scala/minimizer/MinimizerSuite.scala @@ -690,6 +690,16 @@ final class MinimizerSuite extends CatsEffectSuite { run(query, expected) } + test("minimize string arguments which need escapes") { + val query = + """query { character(name: "he said \"hi\"", tag: "a \\ and a \n and a \t") { id } }""" + + val expected = + """query{character(name:"he said \"hi\"",tag:"a \\ and a \n and a \t"){id}}""" + + run(query, expected) + } + test("minimize block string description") { val query = "\"\"\"\nA \"character\".\n\nWith a \\ backslash.\n\"\"\" query Foo { x }" diff --git a/modules/core/src/test/scala/query/QueryRenderSuite.scala b/modules/core/src/test/scala/query/QueryRenderSuite.scala new file mode 100644 index 00000000..0dd127f1 --- /dev/null +++ b/modules/core/src/test/scala/query/QueryRenderSuite.scala @@ -0,0 +1,60 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package query + +import munit.CatsEffectSuite + +import grackle.Query._ +import grackle.Value._ + +final class QueryRenderSuite extends CatsEffectSuite { + test("binding renders the value") { + assertEquals(Binding("id", StringValue("some-id")).render, "id: \"some-id\"") + } + + test("select without a child renders the name only") { + assertEquals(Select("name").render, "name") + } + + test("select renders the alias and the child") { + assertEquals(Select("hero", Some("r2"), Select("name")).render, "r2:hero { name }") + } + + test("untyped select without arguments renders no parentheses") { + assertEquals(UntypedSelect("character", None, Nil, Nil, Empty).render, "character") + } + + test("untyped select renders argument values") { + val query = UntypedSelect( + "character", + None, + List(Binding("id", StringValue("some-id")), Binding("n", IntValue(3))), + Nil, + Empty) + assertEquals(query.render, "character(id: \"some-id\", n: 3)") + } + + test("nested untyped select renders arguments at each level") { + val child = UntypedSelect("friends", None, List(Binding("first", IntValue(1))), Nil, Empty) + val query = + UntypedSelect("hero", None, List(Binding("ep", EnumValue("JEDI"))), Nil, child) + assertEquals(query.render, "hero(ep: JEDI) { friends(first: 1) }") + } + + test("group renders its members in braces") { + assertEquals(Group(List(Select("name"), Select("age"))).render, "{name, age}") + } +} diff --git a/modules/core/src/test/scala/schema/ValueRenderSuite.scala b/modules/core/src/test/scala/schema/ValueRenderSuite.scala new file mode 100644 index 00000000..a941f67d --- /dev/null +++ b/modules/core/src/test/scala/schema/ValueRenderSuite.scala @@ -0,0 +1,84 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schema + +import munit.CatsEffectSuite + +import grackle.SchemaRenderer.renderValue +import grackle.Value._ + +final class ValueRenderSuite extends CatsEffectSuite { + test("int value renders a negative number") { + assertEquals(renderValue(IntValue(-23)), "-23") + } + + test("float value keeps the decimal point") { + assertEquals(renderValue(FloatValue(1.0)), 1.0.toString()) + } + + test("string value is quoted") { + assertEquals(renderValue(StringValue("some-id")), "\"some-id\"") + } + + test("empty string value renders as a pair of quotes") { + assertEquals(renderValue(StringValue("")), "\"\"") + } + + test("boolean value renders without quotes") { + assertEquals(renderValue(BooleanValue(false)), "false") + assertEquals(renderValue(BooleanValue(true)), "true") + } + + test("ID value is quoted like a string") { + assertEquals(renderValue(IDValue("42")), "\"42\"") + } + + test("enum value renders unquoted") { + assertEquals(renderValue(EnumValue("NORTH")), "NORTH") + } + + test("empty list value renders as empty brackets") { + assertEquals(renderValue(ListValue(Nil)), "[]") + } + + test("list value renders nested elements") { + assertEquals( + renderValue(ListValue(List(IntValue(1), ListValue(List(NullValue))))), + "[1, [null]]") + } + + test("empty object value renders as empty braces") { + assertEquals(renderValue(ObjectValue(Nil)), "{}") + } + + test("object value renders its fields") { + assertEquals( + renderValue(ObjectValue(List(("id", StringValue("a")), ("n", IntValue(2))))), + "{id: \"a\", n: 2}") + } + + test("variable reference gets a dollar prefix") { + assertEquals(renderValue(VariableRef("id")), "$id") + } + + test("null value renders as null") { + assertEquals(renderValue(NullValue), "null") + } + + test("absent value renders as null") { + assertEquals(renderValue(AbsentValue), "null") + } +} diff --git a/modules/core/src/test/scala/sdl/SDLSuite.scala b/modules/core/src/test/scala/sdl/SDLSuite.scala index 14a2eda1..df4dba63 100644 --- a/modules/core/src/test/scala/sdl/SDLSuite.scala +++ b/modules/core/src/test/scala/sdl/SDLSuite.scala @@ -668,6 +668,45 @@ final class SDLSuite extends CatsEffectSuite { } } + /** + * String values with might escape string quotes + */ + val trickyStrings: List[(String, String)] = + List( + "quotes" -> "he said \"hi\"", + "backslash" -> "a backslash \\", + "triple quotes" -> s"a ${TQ} b", + "newline" -> "first\nsecond", + "carriage return" -> "first\rsecond", + "tab" -> "first\tsecond", + "control character" -> ("first" + 1.toChar + "second") + ) + + trickyStrings.foreach { + case (label, str) => + test(s"string default value round trips: $label") { + val rendered = SchemaRenderer.renderValue(grackle.Value.StringValue(str)) + + val schema = + s"""|type Query { + | foo(bar: String! = $rendered): Int + |}""".stripMargin + + assertEquals( + schemaParser + .parseText(schema) + .map( + _.definition("Query") + .collect { case o: grackle.ObjectType => o } + .flatMap(_.fields.find(_.name == "foo")) + .flatMap(_.args.find(_.name == "bar")) + .flatMap(_.defaultValue)), + Some(grackle.Value.StringValue(str)).success, + clue = s"rendered as: ${escape(rendered)}" + ) + } + } + /** * Renders control characters visibly, so that failure clues are legible. */