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
1 change: 1 addition & 0 deletions changelog.d/4150-memoise-inflection.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `pluralize()` and `singularize()` cache their results per application, so a repeated word no longer rebuilds the irregular and uncountable lists and the rule table, or runs the rule regexes. Measured per call: 86 µs → 5 µs on RustCFML, about 300 µs → 10 µs on Adobe ColdFusion 2023 / 2025, 152 µs → 12 µs on BoxLang and 25 µs → 5 µs on Lucee 7. The cache is cleared when `set()` changes `uncountables` or `irregulars`, notices a word added to or removed from those settings directly (change an existing irregular through `set()`), is skipped when the current tenant overrides them, keeps a case variant of a word separate, caches only words of up to 100 characters and holds at most 2,000 entries (#4150)
4 changes: 4 additions & 0 deletions vendor/wheels/global/settings.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@
}
} else {
application[local.appKey][StructKeyList(arguments)] = arguments[1];
// Inflection results depend on these; drop the cache (#4150).
if (ListFindNoCase("uncountables,irregulars", StructKeyList(arguments))) {
StructDelete(application[local.appKey], "inflectionCache");
}
// GHSA-8r22: keep the debug-settings boot snapshot in step with an
// explicit set() of these flags, so a runtime set() is still honoured
// while $get() serves the snapshot for an app with the legacy block.
Expand Down
202 changes: 141 additions & 61 deletions vendor/wheels/global/strings.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -24,82 +24,162 @@
numeric count = -1,
boolean returnCount = true
) {
// when count is 1 we don't need to pluralize at all so just return the input string
local.rv = arguments.text;
if (arguments.count != 1) {
local.rv = $cachedInflection(text = arguments.text, which = arguments.which);
}

// return the count number in the string (e.g. "5 sites" instead of just "sites")
if (arguments.returnCount && arguments.count != -1) {
local.rv = LsNumberFormat(arguments.count) & " " & local.rv;
}
return local.rv;
}


/**
* Internal function. $inflect()'s result for `text`, served from an application-scoped
* cache when one can be used (#4150). Struct keys are case-insensitive, so each entry
* keeps the exact text it was computed for: a case variant of a cached word is computed,
* not served the other spelling's result. Long texts aren't cached, and the cache is
* cleared when it reaches its size limit, so arbitrary input can't grow it without bound.
*/
public string function $cachedInflection(required string text, required string which) {
local.cache = $inflectionCache();
if (!IsStruct(local.cache) || Len(arguments.text) > 100) {
return $inflect(text = arguments.text, which = arguments.which);
}
local.key = arguments.which & "|" & arguments.text;
if (StructKeyExists(local.cache.entries, local.key)) {
local.entry = local.cache.entries[local.key];
if (Compare(local.entry.text, arguments.text) == 0) {
return local.entry.rv;
}
return $inflect(text = arguments.text, which = arguments.which);
}
local.rv = $inflect(text = arguments.text, which = arguments.which);
if (StructCount(local.cache.entries) >= 2000) {
StructClear(local.cache.entries);
}
local.cache.entries[local.key] = {text = arguments.text, rv = local.rv};
return local.rv;
}


/**
* Internal function. The inflection cache, or "" when it can't be used: before the
* application settings exist, or when the current tenant overrides `uncountables` or
* `irregulars`. It's rebuilt when those settings change: set() drops it, and a
* fingerprint (the uncountables list and the number of irregulars) catches a word added
* to or removed from the settings struct directly. Changing an existing irregular's plural
* in place isn't detected, so change irregulars through set(). Fingerprinting every pair
* would double the cost of a cache hit.
*/
public any function $inflectionCache() {
if (
StructKeyExists(request, "wheels")
&& StructKeyExists(request.wheels, "tenant")
&& StructKeyExists(request.wheels.tenant, "config")
&& (
StructKeyExists(request.wheels.tenant.config, "uncountables")
|| StructKeyExists(request.wheels.tenant.config, "irregulars")
)
) {
return "";
}
local.appKey = $appKey();
if (!StructKeyExists(application, local.appKey)) {
return "";
}
local.settings = application[local.appKey];
if (!StructKeyExists(local.settings, "uncountables") || !StructKeyExists(local.settings, "irregulars")) {
return "";
}
local.fingerprint = local.settings.uncountables & "|" & StructCount(local.settings.irregulars);
if (
!StructKeyExists(local.settings, "inflectionCache")
|| Compare(local.settings.inflectionCache.fingerprint, local.fingerprint) != 0
) {
local.settings.inflectionCache = {fingerprint = local.fingerprint, entries = {}};
}
return local.settings.inflectionCache;
}


/**
* Internal function. Pluralizes or singularizes `text` (`which` is "pluralize" or
* "singularize") using the uncountable, irregular and regular rules.
*/
public string function $inflect(required string text, required string which) {
// by default we pluralize/singularize the entire string
local.text = arguments.text;

// keep track of the success of any rule matches
local.ruleMatched = false;

// when count is 1 we don't need to pluralize at all so just set the return value to the input string
local.rv = local.text;

if (arguments.count != 1) {
if (ReFind("[A-Z]", local.text)) {
// only pluralize/singularize the last part of a camelCased variable (e.g. in "websiteStatusUpdate" we only change the "update" part)
// also set a variable with the unchanged part of the string (to be prepended before returning final result)
local.upperCasePos = ReFind("[A-Z]", Reverse(local.text));
local.prepend = Mid(local.text, 1, Len(local.text) - local.upperCasePos);
local.text = Reverse(Mid(Reverse(local.text), 1, local.upperCasePos));
}
if (ReFind("[A-Z]", local.text)) {
// only pluralize/singularize the last part of a camelCased variable (e.g. in "websiteStatusUpdate" we only change the "update" part)
// also set a variable with the unchanged part of the string (to be prepended before returning final result)
local.upperCasePos = ReFind("[A-Z]", Reverse(local.text));
local.prepend = Mid(local.text, 1, Len(local.text) - local.upperCasePos);
local.text = Reverse(Mid(Reverse(local.text), 1, local.upperCasePos));
}

// Get global settings for uncountable and irregular words.
// For the irregular ones we need to convert them from a struct to a list.
local.uncountables = $listClean($get("uncountables"));
local.irregulars = "";
local.words = $get("irregulars");
for (local.word in local.words) {
local.irregulars = ListAppend(local.irregulars, LCase(local.word));
local.irregulars = ListAppend(local.irregulars, local.words[local.word]);
}
// Get global settings for uncountable and irregular words.
// For the irregular ones we need to convert them from a struct to a list.
local.uncountables = $listClean($get("uncountables"));
local.irregulars = "";
local.words = $get("irregulars");
for (local.word in local.words) {
local.irregulars = ListAppend(local.irregulars, LCase(local.word));
local.irregulars = ListAppend(local.irregulars, local.words[local.word]);
}

if (ListFindNoCase(local.uncountables, local.text)) {
local.rv = local.text;
local.ruleMatched = true;
} else if (ListFindNoCase(local.irregulars, local.text)) {
local.pos = ListFindNoCase(local.irregulars, local.text);
if (arguments.which == "singularize" && local.pos % 2 == 0) {
local.rv = ListGetAt(local.irregulars, local.pos - 1);
} else if (arguments.which == "pluralize" && local.pos % 2 != 0) {
local.rv = ListGetAt(local.irregulars, local.pos + 1);
} else {
local.rv = local.text;
}
local.ruleMatched = true;
if (ListFindNoCase(local.uncountables, local.text)) {
local.rv = local.text;
local.ruleMatched = true;
} else if (ListFindNoCase(local.irregulars, local.text)) {
local.pos = ListFindNoCase(local.irregulars, local.text);
if (arguments.which == "singularize" && local.pos % 2 == 0) {
local.rv = ListGetAt(local.irregulars, local.pos - 1);
} else if (arguments.which == "pluralize" && local.pos % 2 != 0) {
local.rv = ListGetAt(local.irregulars, local.pos + 1);
} else {
if (arguments.which == "pluralize") {
local.ruleList = "(quiz)$,\1zes,^(ox)$,\1en,([m|l])ouse$,\1ice,(matr|vert|ind)ix,\1ices,ex$,ices,(x|ch|ss|sh)$,\1es,([^aeiouy]|qu)y$,\1ies,(hive)$,\1s,([^f])fe$,\1ves,([lr])f$,\1ves,sis$,ses,([ti])um$,\1a,(buffal|tomat|potat|volcan|her)o$,\1oes,(bu)s$,\1ses,(alias|status)$,\1es,(octop|vir)us$,\1i,(ax|test)is$,\1es,s$,s,$,s";
} else if (arguments.which == "singularize") {
local.ruleList = "(quiz)zes$,\1,(matr)ices$,\1ix,(vert|ind)ices$,\1ex,^(ox)en,\1,(alias|status)es$,\1,(octop|vir|cact|radi|foc)(us|i)$,\1us,(cris|ax|test)es$,\1is,(shoe)s$,\1,(o)es$,\1,(bus)es$,\1,([m|l])ice$,\1ouse,(x|ch|ss|sh)es$,\1,(m)ovies$,\1ovie,(s)eries$,\1eries,([^aeiouy]|qu)ies$,\1y,([lr])ves$,\1f,(tive)s$,\1,(hive)s$,\1,([^f])ves$,\1fe,(database)s$,\1,(^analy)ses$,\1sis,(analy|ba|diagno|parenthe|progno|synop|the)ses$,\1sis,([ti])a$,\1um,(n)ews$,\1ews,(.*)?ss$,\1ss,s$,#Chr(7)#";
}
local.rules = ArrayNew(2);
local.count = 1;
local.iEnd = ListLen(local.ruleList);
for (local.i = 1; local.i <= local.iEnd; local.i = local.i + 2) {
local.rules[local.count][1] = ListGetAt(local.ruleList, local.i);
local.rules[local.count][2] = ListGetAt(local.ruleList, local.i + 1);
local.count = local.count + 1;
}
local.iEnd = ArrayLen(local.rules);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
if (ReFindNoCase(local.rules[local.i][1], local.text)) {
local.rv = ReReplaceNoCase(local.text, local.rules[local.i][1], local.rules[local.i][2]);
local.ruleMatched = true;
break;
}
}
local.rv = Replace(local.rv, Chr(7), "", "all");
local.rv = local.text;
}

// this was a camelCased string and we need to prepend the unchanged part to the result
if (StructKeyExists(local, "prepend") && local.ruleMatched) {
local.rv = local.prepend & local.rv;
local.ruleMatched = true;
} else {
if (arguments.which == "pluralize") {
local.ruleList = "(quiz)$,\1zes,^(ox)$,\1en,([m|l])ouse$,\1ice,(matr|vert|ind)ix,\1ices,ex$,ices,(x|ch|ss|sh)$,\1es,([^aeiouy]|qu)y$,\1ies,(hive)$,\1s,([^f])fe$,\1ves,([lr])f$,\1ves,sis$,ses,([ti])um$,\1a,(buffal|tomat|potat|volcan|her)o$,\1oes,(bu)s$,\1ses,(alias|status)$,\1es,(octop|vir)us$,\1i,(ax|test)is$,\1es,s$,s,$,s";
} else if (arguments.which == "singularize") {
local.ruleList = "(quiz)zes$,\1,(matr)ices$,\1ix,(vert|ind)ices$,\1ex,^(ox)en,\1,(alias|status)es$,\1,(octop|vir|cact|radi|foc)(us|i)$,\1us,(cris|ax|test)es$,\1is,(shoe)s$,\1,(o)es$,\1,(bus)es$,\1,([m|l])ice$,\1ouse,(x|ch|ss|sh)es$,\1,(m)ovies$,\1ovie,(s)eries$,\1eries,([^aeiouy]|qu)ies$,\1y,([lr])ves$,\1f,(tive)s$,\1,(hive)s$,\1,([^f])ves$,\1fe,(database)s$,\1,(^analy)ses$,\1sis,(analy|ba|diagno|parenthe|progno|synop|the)ses$,\1sis,([ti])a$,\1um,(n)ews$,\1ews,(.*)?ss$,\1ss,s$,#Chr(7)#";
}
local.rules = ArrayNew(2);
local.count = 1;
local.iEnd = ListLen(local.ruleList);
for (local.i = 1; local.i <= local.iEnd; local.i = local.i + 2) {
local.rules[local.count][1] = ListGetAt(local.ruleList, local.i);
local.rules[local.count][2] = ListGetAt(local.ruleList, local.i + 1);
local.count = local.count + 1;
}
local.iEnd = ArrayLen(local.rules);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
if (ReFindNoCase(local.rules[local.i][1], local.text)) {
local.rv = ReReplaceNoCase(local.text, local.rules[local.i][1], local.rules[local.i][2]);
local.ruleMatched = true;
break;
}
}
local.rv = Replace(local.rv, Chr(7), "", "all");
}

// return the count number in the string (e.g. "5 sites" instead of just "sites")
if (arguments.returnCount && arguments.count != -1) {
local.rv = LsNumberFormat(arguments.count) & " " & local.rv;
// this was a camelCased string and we need to prepend the unchanged part to the result
if (StructKeyExists(local, "prepend") && local.ruleMatched) {
local.rv = local.prepend & local.rv;
}

return local.rv;
}

Expand Down
145 changes: 145 additions & 0 deletions vendor/wheels/tests/specs/global/InflectionCacheSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* pluralize() / singularize() serve results from an application-scoped cache (#4150).
* These specs pin that a cached result equals the uncached inflection, and that the
* cache follows the inflection settings, case, tenants and its size limit.
*/
component extends="wheels.WheelsTest" {

function run() {

g = application.wo

corpus = [
"item", "items", "product", "category", "categories", "person", "people", "man", "men",
"child", "children", "sex", "move", "moves", "cow", "zombie", "quiz", "quizzes", "ox", "oxen",
"mouse", "mice", "matrix", "matrices", "vertex", "index", "box", "boxes", "church", "class",
"wish", "fly", "flies", "query", "queries", "hive", "wife", "wives", "half", "halves", "analysis",
"analyses", "datum", "data", "buffalo", "tomato", "potato", "hero", "bus", "buses", "alias",
"status", "statuses", "octopus", "virus", "axis", "testis", "crisis", "news", "series", "species",
"fish", "sheep", "rice", "equipment", "information", "software", "feedback", "address", "shoe",
"movie", "movies", "tag", "post", "comment", "user", "account", "invoice", "order", "line",
"Person", "PERSON", "People", "Item", "ITEM", "websiteStatusUpdate", "blogPost", "userAccount",
"orderLineItem", "pageCategory", "mainCategories", "xBox", "AB", "a", "s", "", "ss", "glass"
]

describe("Inflection cache (##4150)", () => {

beforeEach(() => {
settings = application[StructKeyExists(application, "$wheels") ? "$wheels" : "wheels"]
StructDelete(settings, "inflectionCache")
})

it("returns the uncached inflection for every word in the corpus, on a miss and on a hit", () => {
for (var which in ["pluralize", "singularize"]) {
for (var word in corpus) {
var expected = g.$inflect(text = word, which = which)
var miss = which == "pluralize" ? g.pluralize(word) : g.singularize(word)
var hit = which == "pluralize" ? g.pluralize(word) : g.singularize(word)
expect(Compare(miss, expected)).toBe(0, "#which#(#word#) miss: #miss# vs #expected#")
expect(Compare(hit, expected)).toBe(0, "#which#(#word#) hit: #hit# vs #expected#")
}
}
})

it("keeps count and returnCount formatting outside the cache", () => {
expect(g.pluralize("item")).toBe("items")
expect(g.pluralize(word = "item", count = 5)).toBe("5 items")
expect(g.pluralize(word = "item", count = 1)).toBe("1 item")
expect(g.pluralize(word = "item", count = 5, returnCount = false)).toBe("items")
expect(g.pluralize(word = "item", count = 1, returnCount = false)).toBe("item")
})

it("doesn't serve one spelling's result to a case variant", () => {
var lower = g.pluralize("person")
var title = g.pluralize("Person")
var upper = g.pluralize("PERSON")
expect(Compare(lower, g.$inflect(text = "person", which = "pluralize"))).toBe(0)
expect(Compare(title, g.$inflect(text = "Person", which = "pluralize"))).toBe(0)
expect(Compare(upper, g.$inflect(text = "PERSON", which = "pluralize"))).toBe(0)
})

it("follows a set() of uncountables or irregulars", () => {
var saved = {uncountables = settings.uncountables, irregulars = Duplicate(settings.irregulars)}
try {
expect(g.pluralize("zork4150")).toBe("zork4150s")
g.set(uncountables = saved.uncountables & ",zork4150")
expect(g.pluralize("zork4150")).toBe("zork4150")
var irregulars = Duplicate(saved.irregulars)
irregulars["blarg4150"] = "blargen4150"
g.set(irregulars = irregulars)
expect(g.pluralize("blarg4150")).toBe("blargen4150")
} finally {
g.set(uncountables = saved.uncountables)
g.set(irregulars = saved.irregulars)
}
expect(g.pluralize("zork4150")).toBe("zork4150s")
})

it("follows a set() that changes an existing irregular's plural", () => {
var saved = Duplicate(settings.irregulars)
try {
expect(g.pluralize("person")).toBe("people")
var irregulars = Duplicate(saved)
irregulars["person"] = "persons"
g.set(irregulars = irregulars)
expect(g.pluralize("person")).toBe("persons")
} finally {
g.set(irregulars = saved)
}
expect(g.pluralize("person")).toBe("people")
})

it("follows a word added to the irregulars struct directly", () => {
var probe = g.pluralize("glorp4150")
expect(probe).toBe("glorp4150s")
var state = {added = false}
try {
settings.irregulars["glorp4150"] = "glorpi4150"
state.added = true
expect(g.pluralize("glorp4150")).toBe("glorpi4150")
} finally {
StructDelete(settings.irregulars, "glorp4150")
}
expect(state.added).toBeTrue()
expect(g.pluralize("glorp4150")).toBe("glorp4150s")
})

it("bypasses the cache when the tenant overrides the inflection settings", () => {
var cached = g.pluralize("snorf4150")
expect(cached).toBe("snorf4150s")
var hadWheels = StructKeyExists(request, "wheels")
if (!hadWheels) {
request.wheels = {}
}
var hadTenant = StructKeyExists(request.wheels, "tenant")
var savedTenant = hadTenant ? request.wheels.tenant : ""
try {
request.wheels.tenant = {config = {uncountables = settings.uncountables & ",snorf4150"}}
expect(g.pluralize("snorf4150")).toBe("snorf4150")
} finally {
if (hadTenant) {
request.wheels.tenant = savedTenant
} else {
StructDelete(request.wheels, "tenant")
}
}
expect(g.pluralize("snorf4150")).toBe("snorf4150s")
})

it("stays bounded and doesn't cache long text", () => {
for (var i = 1; i <= 2050; i++) {
var w = g.pluralize("word#i#x")
}
expect(StructCount(settings.inflectionCache.entries)).toBeLTE(2000)
var longText = RepeatString("a", 120) & "item"
var before = StructCount(settings.inflectionCache.entries)
var plural = g.pluralize(longText)
expect(plural).toBe(g.$inflect(text = longText, which = "pluralize"))
expect(StructCount(settings.inflectionCache.entries)).toBe(before)
})

})

}

}
Loading