Skip to content
Merged
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
23 changes: 19 additions & 4 deletions generator/src/main/kotlin/norm/generator/TypeRepository.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package norm.generator

import com.squareup.kotlinpoet.ANY
import com.squareup.kotlinpoet.ARRAY
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
Expand Down Expand Up @@ -902,17 +903,31 @@ private fun PropertySource.hasDocumentation(hasTableMapping: Boolean, reservedWo
comment.isNotEmpty() || (!hasTableMapping && sourceReference(reservedWords) != null)

/**
* A regular Kotlin identifier: matched WITHOUT surrounding backticks in KDoc's `@property` tag.
* Whether KotlinPoet would backtick-quote the property declaration it renders for [name].
*
* KotlinPoet escapes a declaration name for four independent reasons -- not a legal Java identifier,
* one of its own reserved `KEYWORDS`, contains `$`, or is all underscores -- and both the rule and
* the keyword set are `internal` to it, so a copy here would drift. Rendering a throwaway
* [PropertySpec] asks KotlinPoet directly instead.
*
* Tests for a backtick anywhere in the rendered text rather than for `` `$name` `` specifically:
* KotlinPoet's line wrapper substitutes a space for the characters it reserves as wrapping markers
* (U+00B7 and U+2662), so such a name is escaped in the output without appearing there verbatim.
* Callers must rule out a name containing its own backtick first -- KotlinPoet treats one as already
* escaped and skips all four checks.
*/
private val PLAIN_KOTLIN_IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*")
private fun needsKotlinPoetDeclarationBackticks(name: String): Boolean =
PropertySpec.builder(name, ANY).build().toString().contains('`')

/**
* Formats a Kotlin property name for use as the name token in a KDoc `@property` tag.
*
* KDoc's `@property` tag takes exactly one name token before the description text begins, so a
* property name containing a space or other non-identifier character (e.g. the Kotlin property
* `` `My Col` `` generated for a quoted SQL column `"My Col"`) must be wrapped in backticks here too
* -- otherwise `@property My Col Some comment.` reads as a property literally named `My`.
* -- otherwise `@property My Col Some comment.` reads as a property literally named `My`. The name is
* left bare only when the declaration KotlinPoet renders for it is bare too, so the two never
* disagree.
*
* Uses [wrapInBacktickDelimiter]'s longest-run rule rather than a fixed single-backtick wrap, since a
* name containing its own literal backtick (e.g. `` a`b ``) would otherwise close the `@property`
Expand All @@ -931,7 +946,7 @@ private val PLAIN_KOTLIN_IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*")
* same field also carries the identifier back into generated SQL and catalog lookups.
*/
private fun String.formatAsKdocPropertyReference(): String? = when {
PLAIN_KOTLIN_IDENTIFIER.matches(this) -> this
!contains('`') && !needsKotlinPoetDeclarationBackticks(this) -> this
containsUnescapableBlockCommentDelimiter(this) -> null
else -> wrapInBacktickDelimiter(this)
}
Expand Down
139 changes: 139 additions & 0 deletions generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.doesNotContain
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

Expand Down Expand Up @@ -358,6 +359,144 @@ class TypeRepositoryTest {
}
}

@Nested
inner class PropertyNameKdocDeclarationAgreement {

@Test
fun `a Unicode-letter property name is bare in both the property tag and the declaration`() {
// Kotlin identifiers accept Unicode letters, so this one needs backticks in neither place.
val unicodeColumn = Column(
name = "café",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getCafe", listOf(unicodeColumn), "SELECT café FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property café Comment.")
assertThat(renderedFile).contains("val café:")
assertThat(renderedFile).doesNotContain("`café`")
}

@Test
fun `a KotlinPoet keyword property name is backtick-quoted in both the property tag and the declaration`() {
// A Kotlin keyword is a legal Java identifier, so only KotlinPoet's own KEYWORDS set catches it.
val keywordColumn = Column(
name = "object",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getObject", listOf(keywordColumn), "SELECT \"object\" FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property `object` Comment.")
assertThat(renderedFile).contains("val `object`:")
}

@Test
fun `an all-underscore property name is backtick-quoted in both the property tag and the declaration`() {
val underscoreColumn = Column(
name = "_",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getUnderscore", listOf(underscoreColumn), "SELECT \"_\" FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property `_` Comment.")
assertThat(renderedFile).contains("val `_`:")
}

@Test
fun `a dollar-sign-containing property name is backtick-quoted in both the property tag and the declaration`() {
// Character.isJavaIdentifierPart('$') is true, so widening only to the Java identifier rule
// would leave this bare while KotlinPoet still backticks the declaration.
val dollarColumn = Column(
name = "a\$b",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getDollar", listOf(dollarColumn), "SELECT \"a\$b\" FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property `a\$b` Comment.")
assertThat(renderedFile).contains("val `a\$b`:")
}

@Test
fun `a space-containing property name is backtick-quoted in both the property tag and the declaration`() {
val spacedColumn = Column(
name = "My Col",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getMyColAgreement", listOf(spacedColumn), "SELECT \"My Col\" FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property `My Col` Comment.")
assertThat(renderedFile).contains("val `My Col`:")
}

@Test
fun `an interpunct-containing property name is backtick-quoted in the property tag`() {
// KotlinPoet reserves U+00B7 as a line-wrapping marker and renders it as a space, so this name
// is escaped in the output without appearing there verbatim. Asserting only that the tag is
// backtick-wrapped: the same substitution mangles the name text in tag and declaration alike,
// which is KotlinPoet's behavior, not this function's.
val interpunctColumn = Column(
name = "col·lecció d'art",
notNull = true,
type = Identifier(name = "text"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery(
"getInterpunct",
listOf(interpunctColumn),
"SELECT \"col·lecció d'art\" FROM t",
)

val renderedFile = repository.requiredTypes.first().toString()
val backtickWrappedPropertyTag = Regex("@property `[^`]*` Comment\\.")
assertThat(backtickWrappedPropertyTag.containsMatchIn(renderedFile)).isTrue()
}

@Test
fun `an ordinary ASCII property name is bare in both the property tag and the declaration`() {
// Regression guard: an ordinary identifier must not gain spurious backticks in either place.
val plainColumn = Column(
name = "id",
notNull = true,
type = Identifier(name = "int4"),
comment = "Comment.",
)

val repository = TypeRepository("test", Catalog())
repository.buildTypeProjectionForQuery("getIdAgreement", listOf(plainColumn), "SELECT id FROM t")

val renderedFile = repository.requiredTypes.first().toString()
assertThat(renderedFile).contains("@property id Comment.")
assertThat(renderedFile).contains("val id:")
assertThat(renderedFile).doesNotContain("`id`")
}
}

@Nested
inner class TableColumnSourceReferenceQuoting {

Expand Down