feat(postgresql): index a pre-built tsvector, making setweight() weighting reachable - #543
Merged
Merged
Conversation
…hable (#541) FullTextIndexDefinition unconditionally wrapped whatever it was given in to_tsvector, so DocumentConfig was always text to be converted and there was no way to hand it an expression that is already a tsvector. That blocks per-member weighting. PostgreSQL's setweight labels a vector, so weighting concatenates the vectors rather than the text -- the expression is a tsvector at the top level, and passing it as DocumentConfig yields to_tsvector('english', setweight(...) || setweight(...)), a type error rather than a weighted index. Raised from JasperFx/marten#5298. Adds TsVectorExpression, consumed without the wrapping, plus a ForTsVector factory (an overload would be ambiguous against the existing constructor). Left unset -- the default -- every existing definition emits the same bytes it always did, pinned by a theory, because a changed index expression means Weasel drops and recreates the index. IndexedTsVector is the one property both the DDL and a consumer's query-side filter read, so the indexed vector and the searched vector cannot drift apart. Two things only a real server showed: - setweight(..) || setweight(..) is an operator expression, and PostgreSQL lets an index expression go bare only when it is a function call or a column reference. correctedExpression supplies exactly one pair of parens, which the USING gin (...) argument list consumes, so the expression is parenthesized on the way in. Canonicalization strips parens, so detection is unaffected. - PostgreSQL renders setweight's weight argument as 'A'::"char" when it gives the index expression back. That cast is in the actual and never in the expected, so a weighted index read as changed on every migration. Stripped in CanonicizeDdl alongside ::text and ::regconfig. Verified against PostgreSQL 17: the index is created, round-trips with no delta, a changed weight is still seen as an update, and ts_rank puts a title match above a description match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
jeremydmiller
added a commit
that referenced
this pull request
Sep 2, 2026
Clears 9.28's known upgrade break, makes the SQLite table rebuild safe against a database that has foreign keys, and adds two capabilities. - #542 closes #538. AssertPatchingIsValid no longer refuses a delta that can rebuild in place, which is what made a numeric or decimal SQLite column throw on 9.28 under every AutoCreate except All. CreateOrUpdate permits a rebuild; CreateOnly still refuses. - #539. The SQLite rebuild runs the way SQLite documents it -- enforcement suspended, one transaction, foreign_key_check before the commit -- instead of four autocommitting statements that wedged the database when the table was referenced. Also fixes an AUTOINCREMENT table reissuing a used id, and a view breaking the rename. - #540. Mutually referencing tables can be created from scratch. A migration holds back only the keys whose target it creates later, so a schema that never had the problem generates byte-for-byte identical DDL. - #543 closes #541. FullTextIndexDefinition can index a pre-built tsvector, making setweight() weighting reachable for JasperFx/marten#5298. - #545. SQLite delete-all names the schema instead of letting SQLite resolve by search order, which put temp first. Minor rather than patch: #543 and #540 add API, and #539 and #542 change what an existing migration does. Ships with one known issue, #546: on SQLite, resetting identity against a schema that has no AUTOINCREMENT table fails with "no such table: <schema>.sqlite_sequence". The single-schema form of this predates 9.29. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #541. Raised from JasperFx/marten#5298.
The constraint
FullTextIndexDefinition.Columnsunconditionally wrapped whatever it was given:So
DocumentConfigwas always text to be converted, and there was no way to hand the type an expression that is already atsvector. PostgreSQL's weighting works by labelling each member's vector and concatenating the vectors, not the text — so the expression is atsvectorat the top level, and passing it asDocumentConfigproducedto_tsvector('english', setweight(…) || setweight(…)): a type error, not a weighted index.The shape
TsVectorExpression, consumed byColumnswithout theto_tsvectorwrapping, plus aForTsVectorfactory. A factory rather than a constructor overload — the existing constructor takes astringin that position followed by three optionalstring?s, so an overload would be ambiguous wherever the optionals are omitted and would silently pick wrong where they are not.Nothing changes for anyone who does not opt in. Left unset, every existing definition emits the byte-identical DDL it always did — pinned by a theory over four
DocumentConfig/RegConfigcombinations, because a changed index expression means Weasel drops and recreates the index, which on a large table is an outage rather than a migration.One property, so the vectors cannot drift
The issue's actual requirement.
IndexedTsVectoryields the vector this index is built over whichever way it was configured, and the DDL is generated from it and nothing else:Marten's
FullTextWhereFragmentreads the expression back off the index definition for exactly this reason, and ranking makes the coupling stricter: ats_rankcomputed over a different vector than the one@@filtered on is silently wrong rather than merely slow. A test assertsIndexedTsVectoris exactly what lands in the DDL for both configurations.Two things only a real server showed
Both were caught by integration tests, not by reading the code.
1. The DDL was a syntax error.
setweight(…) || setweight(…)is an operator expression, and PostgreSQL lets an index expression go bare only when it is a function call or a column reference.IndexDefinition.correctedExpressionsupplies exactly one pair of parentheses, which theUSING gin (…)argument list consumes — so the expression needs its own. It is now parenthesized on the way in, unless the caller already did it. Canonicalization strips parentheses entirely, so delta detection is unaffected either way.2. A weighted index re-read as changed on every migration. PostgreSQL renders
setweight's weight argument with an explicit cast when it gives the index expression back:That cast is in the actual and never in the expected, so the index would have been dropped and recreated on every single migration — on a table big enough to want ranked search, the expensive kind of wrong.
CanonicizeDdlnow strips::"char"alongside the::textand::regconfigit already handled; this is the same class of automatic cast the existingfts_index_comparison_must_take_into_account_automatic_casttest was written for.Tests
Verified against PostgreSQL 17 in a dedicated database.
full_text_index_over_a_prebuilt_tsvector.cs(unit) — DDL shape, no double conversion, name derivation and prefixing,IndexedTsVectorfor both configurations and its equality with what lands in the DDL, empty expression refused, clearing the expression falling back cleanly, and the byte-identical theory for untouched definitions.prebuilt_tsvector_index_deltas.cs(integration) — the weighted index is accepted by the server and round-trips with no delta; a singlesetweight(legal unparenthesized) still round-trips, so the wrapping does not break the case that never needed it; an already-parenthesized expression round-trips; a changed weight is still detected as an update; andts_rankactually puts a title match above a description match, so the tests prove the expression means what the issue was raised to make it mean rather than merely that Weasel emits and re-reads a string.Full
Weasel.Postgresql.Tests(971) andWeasel.Core.Tests(320) green.Docs
docs/postgresql/tables.mdgains a Full Text Indexes section with a weighting subsection, from compilable mdsnippets insrc/DocSamples.🤖 Generated with Claude Code