Skip to content

feat(postgresql): index a pre-built tsvector, making setweight() weighting reachable - #543

Merged
jeremydmiller merged 1 commit into
masterfrom
feat-541-tsvector-expression
Sep 2, 2026
Merged

jeremydmiller merged 1 commit into
masterfrom
feat-541-tsvector-expression

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #541. Raised from JasperFx/marten#5298.

The constraint

FullTextIndexDefinition.Columns unconditionally wrapped whatever it was given:

get => new[] { $"to_tsvector('{regConfig}',{DocumentConfig.Trim()})" };

So DocumentConfig was always text to be converted, and there was no way to hand the type an expression that is already a tsvector. PostgreSQL's weighting works by labelling each member's vector and concatenating the vectors, not the text — so the expression is a tsvector at the top level, and passing it as DocumentConfig produced to_tsvector('english', setweight(…) || setweight(…)): a type error, not a weighted index.

The shape

TsVectorExpression, consumed by Columns without the to_tsvector wrapping, plus a ForTsVector factory. A factory rather than a constructor overload — the existing constructor takes a string in that position followed by three optional string?s, so an overload would be ambiguous wherever the optionals are omitted and would silently pick wrong where they are not.

var weighted =
    "setweight(to_tsvector('english', coalesce(data ->> 'Title', '')), 'A') || " +
    "setweight(to_tsvector('english', coalesce(data ->> 'Body', '')), 'B')";

var index = FullTextIndexDefinition.ForTsVector(tableName, weighted);

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/RegConfig combinations, 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. IndexedTsVector yields the vector this index is built over whichever way it was configured, and the DDL is generated from it and nothing else:

public string IndexedTsVector =>
    tsVectorExpression == null
        ? $"to_tsvector('{regConfig}',{DocumentConfig.Trim()})"
        : parenthesize(tsVectorExpression);

Marten's FullTextWhereFragment reads the expression back off the index definition for exactly this reason, and ranking makes the coupling stricter: a ts_rank computed over a different vector than the one @@ filtered on is silently wrong rather than merely slow. A test asserts IndexedTsVector is 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.correctedExpression supplies exactly one pair of parentheses, which the USING 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:

expected: … setweightto_tsvector'english',coalescedata->>'title','','a' || …
actual:   … setweightto_tsvector'english',coalescedata->>'title','','a'::"char" || …

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. CanonicizeDdl now strips ::"char" alongside the ::text and ::regconfig it already handled; this is the same class of automatic cast the existing fts_index_comparison_must_take_into_account_automatic_cast test 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, IndexedTsVector for 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 single setweight (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; and ts_rank actually 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) and Weasel.Core.Tests (320) green.

Docs

docs/postgresql/tables.md gains a Full Text Indexes section with a weighting subsection, from compilable mdsnippets in src/DocSamples.

🤖 Generated with Claude Code

…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>
@jeremydmiller
jeremydmiller merged commit 8418bb6 into master Sep 2, 2026
18 checks passed
@jeremydmiller
jeremydmiller deleted the feat-541-tsvector-expression branch September 2, 2026 10:28
@jeremydmiller jeremydmiller mentioned this pull request Sep 2, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FullTextIndexDefinition cannot express a pre-built tsvector, so setweight() weighting is unreachable

1 participant