aliases are normalized and used in normalized state, but nonnormalized aliases contain everything

This commit is contained in:
Filipriec
2026-08-25 10:22:27 +02:00
parent 2e1b213137
commit e6840f6d9b
19 changed files with 198 additions and 34 deletions

View File

@@ -21,6 +21,9 @@ serde = { version = "1.0.228", features = ["derive"] }
tantivy = { workspace = true, optional = true }
serde_json.workspace = true
tonic-prost = "0.14.6"
unicode-normalization.workspace = true
unicode-properties.workspace = true
icu_casemap.workspace = true
[build-dependencies]
tonic-build = { version = "0.14.6" }

View File

@@ -147,7 +147,12 @@ message GeneratedColumnAlias {
// ignored, so a typo cannot pass silently.
string generated_name = 1;
// What the column should be called instead. Same rules as any column name.
// What the column should be called instead. Preserved exactly for display.
// Aliases may contain Unicode letters, numbers, spaces, punctuation and
// symbols, but not leading/trailing whitespace or control characters, and
// are limited to 63 UTF-8 bytes. Uniqueness and lookup use a compatibility-
// normalized, Unicode-case-folded key that collapses whitespace and ignores
// emoji while keeping punctuation significant.
string alias = 2;
}

92
common/src/alias.rs Normal file
View File

@@ -0,0 +1,92 @@
use icu_casemap::CaseMapper;
use unicode_normalization::UnicodeNormalization;
use unicode_properties::emoji::{
is_emoji_presentation_selector, is_tag_character, is_text_presentation_selector, is_zwj,
};
use unicode_properties::{GeneralCategoryGroup, UnicodeEmoji, UnicodeGeneralCategory};
/// PostgreSQL's identifier limit. Display aliases are used as result-column
/// labels in a few query paths, so the limit is measured in UTF-8 bytes.
pub const MAX_ALIAS_BYTES: usize = 63;
/// Whether a display alias contains control, formatting, private-use, or
/// unassigned characters that should never be persisted as visible naming.
pub fn has_disallowed_alias_characters(alias: &str) -> bool {
alias
.chars()
.any(|character| {
character.general_category_group() == GeneralCategoryGroup::Other
&& !is_zwj(character)
&& !is_emoji_presentation_selector(character)
&& !is_text_presentation_selector(character)
&& !is_tag_character(character)
})
}
/// The hidden key used to compare and resolve public column aliases.
///
/// Display spelling is never changed. Compatibility-equivalent spelling and
/// case compare alike, whitespace runs compare as one space, and emoji are
/// ignored. Letters, marks, numbers, punctuation and useful non-emoji symbols
/// remain significant.
pub fn canonical_alias(alias: &str) -> String {
let compatible = alias.nfkc().collect::<String>();
let folded = CaseMapper::new().fold_string(&compatible).into_owned();
let mut canonical = String::new();
let mut pending_space = false;
for character in folded.chars() {
match character.general_category_group() {
GeneralCategoryGroup::Letter
| GeneralCategoryGroup::Mark
| GeneralCategoryGroup::Number
| GeneralCategoryGroup::Punctuation => {
if pending_space && !canonical.is_empty() {
canonical.push(' ');
}
pending_space = false;
canonical.push(character);
}
GeneralCategoryGroup::Symbol if !character.is_emoji_char() => {
if pending_space && !canonical.is_empty() {
canonical.push(' ');
}
pending_space = false;
canonical.push(character);
}
GeneralCategoryGroup::Separator => {
pending_space = !canonical.is_empty();
}
GeneralCategoryGroup::Symbol | GeneralCategoryGroup::Other => {}
}
}
canonical.nfc().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliases_compare_by_compatibility_case_and_without_emoji() {
assert_eq!(canonical_alias("Apple"), "apple");
assert_eq!(canonical_alias("APPLE🙂"), "apple");
assert_eq!(canonical_alias(""), "apple");
assert_eq!(canonical_alias("Straße"), canonical_alias("STRASSE"));
assert_eq!(canonical_alias("Apple👨👩👧"), "apple");
assert!(!has_disallowed_alias_characters("Apple👨👩👧"));
}
#[test]
fn punctuation_and_useful_symbols_remain_significant() {
assert_eq!(canonical_alias("Apple."), "apple.");
assert_eq!(canonical_alias("Apple!"), "apple!");
assert_eq!(canonical_alias("Price €"), "price €");
}
#[test]
fn whitespace_runs_compare_as_one_space() {
assert_eq!(canonical_alias("First Name"), "first name");
}
}

View File

@@ -2,6 +2,7 @@
#[cfg_attr(not(feature = "tantivy"), path = "search_light.rs")]
pub mod search;
pub mod alias;
pub mod decimal;
pub mod grpc_error;
pub mod money;

Binary file not shown.

View File

@@ -90,7 +90,12 @@ pub struct GeneratedColumnAlias {
/// ignored, so a typo cannot pass silently.
#[prost(string, tag = "1")]
pub generated_name: ::prost::alloc::string::String,
/// What the column should be called instead. Same rules as any column name.
/// What the column should be called instead. Preserved exactly for display.
/// Aliases may contain Unicode letters, numbers, spaces, punctuation and
/// symbols, but not leading/trailing whitespace or control characters, and
/// are limited to 63 UTF-8 bytes. Uniqueness and lookup use a compatibility-
/// normalized, Unicode-case-folded key that collapses whitespace and ignores
/// emoji while keeping punctuation significant.
#[prost(string, tag = "2")]
pub alias: ::prost::alloc::string::String,
}

View File

@@ -120,7 +120,7 @@ pub fn canonical_search_number(input: &str) -> String {
/// Normalizes a column name to the JSON-key form used at index time.
pub fn normalize_column_name(column: &str) -> String {
column.to_ascii_lowercase()
crate::alias::canonical_alias(column)
}
/// Creates the column-aware search schema.

View File

@@ -65,7 +65,7 @@ pub fn canonical_search_number(input: &str) -> String {
}
pub fn normalize_column_name(column: &str) -> String {
column.to_ascii_lowercase()
crate::alias::canonical_alias(column)
}
#[cfg(test)]