diff --git a/changelog.d/4150-memoise-inflection.performance.md b/changelog.d/4150-memoise-inflection.performance.md new file mode 100644 index 0000000000..9d226e26cd --- /dev/null +++ b/changelog.d/4150-memoise-inflection.performance.md @@ -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) diff --git a/vendor/wheels/global/settings.cfm b/vendor/wheels/global/settings.cfm index 995a7e8a54..7c67b6479b 100644 --- a/vendor/wheels/global/settings.cfm +++ b/vendor/wheels/global/settings.cfm @@ -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. diff --git a/vendor/wheels/global/strings.cfm b/vendor/wheels/global/strings.cfm index 436881348d..df7907edd3 100644 --- a/vendor/wheels/global/strings.cfm +++ b/vendor/wheels/global/strings.cfm @@ -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; } diff --git a/vendor/wheels/tests/specs/global/InflectionCacheSpec.cfc b/vendor/wheels/tests/specs/global/InflectionCacheSpec.cfc new file mode 100644 index 0000000000..bbe3768c89 --- /dev/null +++ b/vendor/wheels/tests/specs/global/InflectionCacheSpec.cfc @@ -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) + }) + + }) + + } + +}