v0.3.0
After upgrading, run fuzzphony:schema --apply (idempotent) so existing indexes get the new
trigger and sync functions, then a full fuzzphony:reindex to clear what earlier TRUNCATEs left
behind. See UPGRADE.md.
Breaking
- The minimum Symfony version is 7.4 (was 7.3). 7.3 is end-of-life and every
symfony/yaml
7.3.x release carries security advisories, so Composer refuses to install a 7.3-pinned set. fuzzphony:search: the long option--profileis now--rank-profile(-pis unchanged). The
old name collided with Symfony FrameworkBundle's global--profileflag and made the command
fail with "An option named 'profile' already exists".EnginegainedpruneOrphans(); a third-party engine must implement it.- A full
fuzzphony:reindex(andFuzzphony::reindex()) now removes orphaned documents at the
end. Opt out with--no-prune/prune: false. NodeInspector::topLevelExclusions()was removed (no engine uses it any more).
SearchSqlBuilder::ranked()(@internal) changed signature.Thresholdsrejectscandidate_limitabove 10 000,max_query_lengthabove 1 024 and
max_termsabove 64, and an unknownfuzzy_modethrowsInvalidDefinition(it used to throw a
bareValueError).- A source query or watch SQL that contains
$fuzzphony$is rejected by the definition validator. - The
ext-pdo_pgsqlextension is now a hard requirement offuzzphony/fuzzphony(the
PostgreSQL engine is always part of the package); install it before upgrading.
Added
- Empty-result relaxation. When a query of two or more words finds nothing, each word is
checked on its own against the searched set (filters and tenant included, with the same exact or
typo-tolerant condition the search uses) in one probe statement. The words that match nothing
are dropped and the search runs again;SearchResult::$warningssays
No results for all words; ignored words that match nothing: "aluminum".andinterpretedAs
shows the reduced query. On the demo cataloguewireless mouse aluminum(the word is only in
descriptions) returns the 1 666 wireless mice instead of nothing.- Never relaxed: words that all match something but never together (
mouse kettle), single
words, queries with hits, and a reduction that would leave a group of only negations
(zzqq -mouse | wireless yyqq). If the relaxed search finds nothing too, the original empty
result stands, without a warning. - The warning is plain text that quotes the user's words (invisible format characters
removed, cut at 40 characters, each word once): escape it when you render it as HTML. - New threshold
relax_when_empty(defaulttrue, independent offuzzy_mode).explain()
lists therelaxation probeandrelaxed: …statements and shows the plan of the last search
statement. - The probe reads the text indexes through one materialized CTE per word (10–35 ms at 200 000
rows) and changes no session or transaction setting.
- Never relaxed: words that all match something but never together (
TRUNCATEsync.triggerandqueuesync add anAFTER TRUNCATE … FOR EACH STATEMENT
trigger to every watched table, at both trigger levels. Truncating a table-sourced index's own
table empties the index when the source really is empty (and drops its queued ids, skipping
rows a running worker holds); otherwise, and for any other watched table, every indexed document
and every document the source now returns is resynced (queued inqueuemode, refreshed inside
the transaction intriggermode: expensive on a big index, see "Known limitations").
Truncating a single partition directly still fires nothing.- Orphan pruning.
Engine::pruneOrphans()removes, in batches, documents whose row the source
no longer returns. A full reindex calls it and reports the count; a run resumed with--from
never prunes. A full run whose source returns no row at all does not prune unless
--prune-empty/pruneEmpty: trueis given ($onPruneSkippedreports it).
Fuzzphony::reindex()accepts$onPruned,$prune,$pruneEmptyand$onPruneSkipped. fuzzphony:doctorreports a missingTRUNCATEtrigger and, with--deep, counts orphaned
documents.- Test coverage for the Symfony bundle and the Doctrine bridge, previously untested: DI wiring,
thefuzzphony:*commands,OrmSyncListener,EntityLoader(one query, ranking order),
FuzzphonySearchFilter, the Live Component, and console option collisions with FrameworkBundle. - CI: a
composer validate --strictgate, a Symfony 7.4 / 8.0 × PHP 8.4 / 8.5 × PostgreSQL 15–18
matrix, a--prefer-lowestjob, a demo smoke job, and Dependabot for Composer and GitHub
Actions. authors,homepageandsupportmetadata incomposer.json;UPGRADE.md.
Changed
- Typo-tolerant (fuzzy) matching is per word. It used to compare the whole query as one string
with all fuzzy fields, so one long common word could satisfy it on its own: on the demo
cataloguewireles micereturned 20 000 products (chairs, drills, kettles, …) of which 1 666
were mice. Every word must now match on its own, exactly or by trigram similarity, through the
query's real AND / OR / NOT structure, andwireles micereturns exactly the 1 666 wireless mice.- Fuzzy result sets get narrower. A typo in a word found only in a non-fuzzy field can no longer
be matched approximately; such a query finding nothing is relaxed instead (see Added). - Negations are honoured at any depth by the fuzzy branch (previously only top-level ones).
ScoreBreakdown::$fuzzySimilarityis per word (1.0 for an exact word, AND = mean, OR = max),
so scores of strict matches shift slightly infuzzy_mode: always.fuzzy_min_lengthapplies per word, and stop words of the index language are ignored like in
the full-text query.- Measured at 1 000 000 rows: at most 1.3× the old fuzzy statement's time, still on the GIN
indexes.
- Fuzzy result sets get narrower. A typo in a word found only in a non-fuzzy field can no longer
fuzzphony:doctorwarns about acandidate_limitabove 5 000 (was 20 000, which is now above
the cap).- The similarity threshold a fuzzy statement sets is restored afterwards, so a search inside a
caller's own transaction leaves no setting behind. - The dev-only parts of the repository (
demo/,benchmarks/,docs/,tests/, CI and tool
configuration) are no longer part of the Composer package. - Demo:
demo/docker-compose.ymlis a production-like stack (nginx + php-fpm, a worker, a
one-shot idempotentinit, a tuned PostgreSQL 17 with a named volume) built from an immutable
multi-stage image;docker-compose.dev.ymlkeeps live editing. The old demo volume is not
reused: the first start seeds again.
Security
- A source query or watch
affectedIdscontaining$fuzzphony$, the dollar-quote tag of the
generated functions, could break out of the generated function body; it is now rejected. The
wizard skips table, column and foreign-key names that are not plain identifiers instead of
building SQL from them. - Threshold overrides can no longer lift the cost limits (see Breaking).
- Chained exclusions (
NOT NOT …,- - …) are parsed in a loop instead of recursively; long
chains are collapsed with the warningRepeated exclusions ("-" / NOT) were collapsed. - Demo: published on 127.0.0.1 only by default, refuses to start exposed with the default secret
or password, connects as a non-superuser role with a 5 sstatement_timeout, clamps every
playground input, makes EXPLAIN ANALYZE and the deep doctor opt-in (DEMO_ALLOW_ANALYZE,
DEMO_ALLOW_DEEP_DOCTOR), accepts only listed tables in the web wizard, and escapes the hit
title when there is no highlight.
Fixed
<twig:Fuzzphony:Search />failed with "There are no registered paths for namespace Fuzzphony"
in every real installation:FuzzphonyBundle::getPath()pointed one directory too high.TRUNCATEon a source or watched table left stale documents in the index forever, and not even
fuzzphony:reindexremoved them (see Added).TRUNCATE ONLYon a table-inheritance parent no longer empties the index while the children's
rows are still in the source.- Demo: the CSP blocked AssetMapper's importmap entry for
app.js's stylesheet import, which
abortedapp.jsso no JavaScript ran; the demo'sbenchmarks/seed.sqlassigned categories
independently of product names. - Docs: README examples use snake_case filter names, and the tenant-scoped example uses a
tenant-scoped index.
Full changelog: v0.2.0...v0.3.0