diff --git a/generator/src/main/kotlin/norm/generator/TypeRepository.kt b/generator/src/main/kotlin/norm/generator/TypeRepository.kt index 3b95620..3017017 100644 --- a/generator/src/main/kotlin/norm/generator/TypeRepository.kt +++ b/generator/src/main/kotlin/norm/generator/TypeRepository.kt @@ -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 @@ -902,9 +903,21 @@ 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. @@ -912,7 +925,9 @@ private val PLAIN_KOTLIN_IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*") * 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` @@ -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) } diff --git a/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt b/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt index df4e7f4..adefa9c 100644 --- a/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt +++ b/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt @@ -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 @@ -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 {