Implementing a table-valued function

tl;dr can anyone point me towards how I can ask the ORM to spit out just a table’s name, not table.column, in a SQL expression?

I’ve built some infrastructure around SQLite’s FTS5 system for a small, personal app. (I tried to link it, but as a new user, I’m only allowed 2 links which I’m spending elsewhere in this post. sqlite.org/fts5.html will get you there.) The design is roughly:

  • A manual migration which creates the virtual table (code listing 1)
  • An unmanaged table wrapping it (2)
  • A method to perform a search (3)
  • Infrastructure for keeping the FTS index up to date (outside the scope of what’s I’m asking for here)

My original search method used .raw() to invoke FTS5’s table-valued syntax: SELECT * FROM search_v4(search_terms *);. This gets me a RawQueryset, which is poorly-featured compared to a full Queryset (e.g. I can’t do things like .first() or take slices) and has some more quirks (one I recall is having to write SELECT rowid, * ... or else Paginator didn’t work, though I may be misremembering that).

My wrapper model should be capable of fully treating the virtual table as a (read-only) regular table, though, and that would get me a full Queryset. I went down a rabbit hole trying to get Django ORM to emit either table-valued function syntax or, as a fallback, FTS5’s other syntax: SELECT * FROM table WHERE table = search_terms. I want to be able to write SearchDoc.objects.table_name('search terms') or, in fallback terms, to something like SearchDoc.objects.filter(search_v4='search terms').

The ORM really wants to express column references, not table references. I could not figure out how to write a standalone Expression or other Query API object that would give me the table name in the right place, nor how to plug it into the object manager as .table_name(). In my fallback syntax, search_v4 isn’t a known field name, so that also fails.

I ended up with a hack: a Lookup which eats the field name in its as_sql method. This feels extremely brittle in ways I can’t quite put my finger on, and it’s unergonomic. To use it, you pick any arbitrary field knowing that it’ll be thrown away: SearchDoc.objects.filter(body__table_match='search terms'). It generates the table = search_terms version of the SQL. The code listing for this hack is shown below. I’d prefer to replace .filter() with some kind of .table_name() construct, or failing that, allow search_v4= (or more generically, perhaps _table_name=) as an argument to filter.

Can anyone point me in the right direction for where I’d look to inject this capability? To be clear, I’m not proposing Django adopt this as a core feature, I’m just looking to solve my problem with the least-hacky thing. It feels like between Expression and Lookup and all the other first-class ways to extend Django’s SQL generation, I must be missing something simple.

Code snippets:

-- 1: virtual table for FTS5
CREATE VIRTUAL TABLE search_v4 USING fts5(
    body,
    object_id UNINDEXED,
    content_type_id UNINDEXED,
    tokenize = 'trigram'
);
// 2 - unmanaged wrapper table
class SearchDoc(models.Model):
    class Meta:
        managed = False
        db_table = 'search_v4'

    id = models.IntegerField(db_column='rowid', primary_key=True)
    # search document
    body = models.TextField()
    # search result rank
    rank = models.FloatField(null=True)
    # next 3 are links to searchable entities
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveBigIntegerField()
    content_object = GenericForeignKey("content_type", "object_id")

    def __str__(self):
        return f'"{self.body.replace('\n', '\\n')}"'
// 3 - current search implementation
def search(raw_terms: str):
    # see the SQLite fts5 docs about FTS5 strings
    quoted_terms = raw_terms.replace('"', '""')
    query_text = f'"{quoted_terms}" *'

    return (
        SearchDoc.objects
        .filter(body__table_match=query_text)
        .order_by('rank')
    )
// 4 - <field>__table_match= hack
@models.Field.register_lookup
class TableMatch(models.Lookup):
    lookup_name = 'table_match'

    def as_sql(self, compiler, connection):
        # attempt to prevent this hack from being a source of unexplainable bugs
        if not isinstance(self.lhs, Col):
            raise ValueError('choose a column on the model to use __table_match')
        # use just the table alias as SQL
        lhs, lhs_params = self.lhs.alias, ()
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return "%s = %s" % (lhs, rhs), params

I searched existing forum posts, tickets, and DEPs before posting. I found a few that looked related, but nothing that solved my problem nor seemed like it was moving in that direction. For reference, they are about adding generate_series support (a Postgres-specific table-valued function) and a related ticket (37115) + DEP - sorry for not linking the ticket, but again, I only get 2 links in this post :frowning: . Although it’s using the words I want – table-valued function – the focus of that work seems to be on modeling CompositeField as a precursor. I think my use case is a much narrower thing which doesn’t require any new primitives like that, though I could be wrong.

Thank you!

1 Like

This is my first time working with FTS5, so I did a little research before replying. I found your question because I’m working on a related Django ORM feature in PR #21636. That work involves multi-column subqueries.
Please correct me , If I understand correctly, your migration creates the FTS5 virtual table, and the unmanaged model points to that table.

In body__table_match, the body field is only used to get the current table alias. The body column itself is not used.

I don’t think Django currently has a public API for referring only to a table, or for using a table-valued function directly in the FROM clause.

However, FTS5 provides a hidden column with the same name as the virtual table. You could map that column in your model:

fts_query = models.TextField(
    db_column="search_v4",
    editable=False,
)

Then you could write:

SearchDoc.objects.filter(fts_query=query_text)

Django should generate something similar to:

WHERE "search_v4"."search_v4" = %s

SQLite accepts this syntax and treats it as an FTS5 search.

You could hide this detail inside a custom queryset method:

class SearchDocQuerySet(models.QuerySet):
    def search(self, query_text):
        return (
            self.filter(fts_query=query_text)
            .defer("fts_query")
            .order_by("rank")
        )

This returns a normal queryset, so methods such as .filter(), .first(), and slicing will still work.

1 Like

You’re not really fighting Lookup here—you’re fighting a deeper assumption in Django’s ORM:

The ORM is built around expressions that evaluate to values, not relations.

A table name (or table alias) in FROM/MATCH position is not an expression in SQL. It’s part of the query structure. Django has lots of extension points for expressions (Func, Expression, Lookup, Transform, etc.), but very few for introducing new relation-level constructs.

A few thoughts on the options you’ve explored:

Your Lookup isn’t as hacky as it looks

The implementation

lhs = self.lhs.alias
return "%s = %s" % (lhs, rhs), params

is actually fairly reasonable because Col.alias is the compiler’s notion of the table alias, which is exactly what SQLite FTS expects.

I’d probably use

compiler.quote_name_unless_alias(self.lhs.alias)

rather than emitting the alias directly, but conceptually it’s sound.

The ugly part is purely the API:

.filter(body__table_match=query)

where body is ignored.


A custom QuerySet method is probably the nicest API

Instead of exposing the lookup, wrap it:

class SearchQuerySet(models.QuerySet):
    def match(self, query):
        return self.filter(body__table_match=query)

class SearchDoc(models.Model):
    objects = SearchQuerySet.as_manager()

Then callers write

SearchDoc.objects.match(query).order_by("rank")

The implementation still uses the lookup internally, but nobody else has to know.


Why filter(search_v4=query) won’t work

The keyword arguments to filter() are parsed as

field__lookup

where field must resolve to a model field (or annotation).

There’s no extension hook that says “if this isn’t a field, treat it as the table.”

So something like

.filter(search_v4=query)

can’t really be implemented without modifying the ORM’s field resolution.


Expressions won’t help

You mentioned trying Expression.

The problem is that expressions compile to things like

LOWER(body)
body
COALESCE(...)

They don’t compile to relation identifiers.

Even if you wrote

class TableName(Expression):
    ...

there’s nowhere legal to put that expression so that Django would emit

search_v4 = ?

instead of

(TableName(...)) = ?

The compiler simply isn’t expecting relation references there.


A custom Query is theoretically possible…

…but it’s much more invasive.

You could subclass Query or SQLCompiler and override parts of build_filter() or WhereNode generation so that a sentinel expression produces the FTS syntax.

That’s a lot more fragile than your current lookup and depends on internal APIs that change fairly regularly.

I wouldn’t recommend it unless you’re building a reusable backend.


If you specifically want the table-valued syntax

FROM search_v4(?)

that’s even harder.

That changes the FROM clause itself, which Django currently has essentially no public extension point for.

The work around table-valued functions and generate_series() that you found exists precisely because Django doesn’t currently have a good abstraction for relation-valued expressions.


My recommendation

I would keep the lookup and hide it behind a queryset method:

class SearchQuerySet(models.QuerySet):
    def search(self, terms):
        quoted = terms.replace('"', '""')
        query = f'"{quoted}" *'
        return self.filter(body__table_match=query)

so callers simply write

SearchDoc.objects.search("foo").order_by("rank")

That gives you a clean public API while confining the “abuse” of Lookup to one implementation detail.

In fact, if this were proposed on Django’s internals list, I suspect maintainers would probably consider a custom lookup plus a queryset method to be the idiomatic solution today. Until Django grows first-class support for relation-valued expressions (the work around table-valued functions is moving in that direction), there isn’t a cleaner public extension point for “emit the table alias here instead of a column.”

1 Like

Thank you both VERY much! I appreciate you taking the time to help me understand both SQLite FTS and Django internals better. I’m also relieved to know that my Lookup hack isn’t as gross as I feared.