Skip to main content
An extension can register its own index structure and have the server call into it. The server owns the SQL syntax, the metadata and the transaction. The extension owns the on-disk format of the index and the search through it. Two preview capabilities carry this. vsql::preview::index_type registers the index structure itself. vsql::preview::index_profile binds that structure to a custom type and names the functions the index computes with. Both live in <villagesql/preview/index_builder.h> in the dev ABI headers, so an extension that uses them builds with -DVSQL_USE_DEV_ABI=ON.
Supported today: the DDL that creates, alters and drops a custom index; index maintenance on INSERT; and one query shape, a nearest-neighbour ordering served by the index.Not supported: DELETE, an UPDATE that changes an indexed column, and every access pattern other than nearest-neighbour search. Four of the twelve required hooks — mark_delete, purge, save and restore — must exist for the builder to compile and the server never calls them.Statements naming a custom index are refused unless vsql_allow_preview_extensions is ON, the same setting that installing a preview extension already requires.

The Three Pieces

An index type is the structure: how entries are stored, inserted and scanned. It is registered once per extension per structure, under a name like hnsw. An index function is a deterministic function bound to a profile — a distance function, for instance. A profile binds two kinds. A function bound with with_function() is one the optimizer may plan an index scan for. A function bound with with_helper() is called only by the index implementation. Binding a function to a profile does not create a SQL function. The optimizer matches an ORDER BY call by name against the profile’s bindings, so a function bound with with_function() must also be registered with .func() under the same name, or no statement can ever name it and the index is never chosen. A helper needs no .func(), and calling one from SQL gives ERROR 1305 (42000): FUNCTION <database>.<name> does not exist. An index profile ties a custom type to an index type and gives each bound function a numeric id the index calls it by. One index type serves many profiles: vsql_vector registers a single hnsw index type and four profiles over it — hnsw_l1, hnsw_l2, hnsw_cosine and hnsw_inner_product — one per distance metric.

Registering an Index Type

make_index_type<Name, Context>() takes the index name and a per-index context struct that holds whatever run-time state the extension needs. All twelve hooks are required, in three groups, in this order. The builder does not compile if one is missing — the failing assertion names the hook. The lifecycle hooks create the index storage, load it when the table opens, and drop it:
The DML hooks maintain the index as rows change. The server calls insert. mark_delete and purge must exist with these signatures so the builder compiles; stub them:
The scan hooks answer queries. The server calls begin, position, fetch and end, and passes position only Index::CursorOp::Next. save and restore must exist and are stubs, like the two DML hooks above:
Every hook that can fail takes a message buffer and its length as the last two arguments, and returns true on failure after writing the message there. The cursor belongs to the extension. Allocate it in begin and assign it to *cursor, already positioned on the first record — the server calls fetch before it calls position. Free the cursor in end and set *cursor to nullptr. The SDK does not wrap cursor state. The bool * argument of begin, position and restore is an end-of-scan flag the hook writes. On success the server calls end even when begin set that flag; on failure begin must leave *cursor null, and end is not called. The MtrCtx::Ref begin receives is null on the path the server drives today. The context does not belong to the extension. The SDK allocates Index::StorageCtx<T> and your T from the InnoDB arena before it calls create or load, and destroys the arena — which runs ~T — after drop returns. Reach your own state through ctx->user() and the arena through ctx->arena(). Never delete the context, the user object or the arena.

Declaring What the Index Can Do

.capabilities() declares which access patterns the index supports, and .storage_props() declares how each index entry refers back to the data it indexes. Both are required. The builder accepts five capability values, and the server acts on one: Index::Support::KNN, a k nearest neighbour scan for ORDER BY <distance>(<column>, <reference>) LIMIT K. POINT_LOOKUP, RANGE_SCAN, REVERSE_SCAN and ORDER_BY compile and register, and InnoDB reports no access flags for a custom index, so the optimizer builds none of those paths. The supported storage declaration is the pair Index::Storage::HAS_COLUMN_REF | Index::Storage::REF_LOOKUP on a single-column index over a column the extension stores itself. HAS_COLUMN_REF means each entry stores a stable column reference the server supplies rather than the full primary key, and it is what makes get_key_data() and get_key_ref() available. REF_LOOKUP means the index can re-locate an entry from that reference, and it carries an obligation: every entry must have a unique reference token. Index::Storage::HAS_ROW_REF is the third value. It fills the pkeys array insert() receives, and nothing reads it back. Declare the pair above.

Reading and Writing Column References

get_key_data() resolves a KeyPartRef — the kind the DML and scan hooks hand the index — into the key column’s actual value, sized with get_max_col_len(key_pos). get_key_ref() goes the other way: it derives the reference for a key column value already in the column store, such as one of the keys insert() receives. get_key_data() reads under its own mini-transaction and must not be called while the extension holds page latches. Both take key_pos, the zero-based key column the value belongs to, and return true on failure with the message in index.get_error():
The server leaves both as null function pointers unless the index declared HAS_COLUMN_REF, and the SDK calls them unguarded, so calling either without it crashes rather than returning an error.

Index Options

WITH (M = 8, ef_construction = 64) in the DDL reaches the extension as index options. The server lowercases every key and value first, so params[i].key reads m even though the DDL wrote M. Declare a struct with defaults and a parse function, and register it with .options<T, &T::parse>():
Compare against the lowercased key:
params is null when the DDL carried no WITH clause, so count is the value to test first. A parse that returns failure aborts the DDL, and the message it wrote goes to the server error log — the client sees only ERROR 1030 (HY000): Got error 168 - 'Unknown (generic) error from engine', so name the index and the offending key in the message. index.options<HNSWOptions>() returns a pointer to the parsed struct. It is reachable from every hook that receives the Index — the lifecycle hooks, the DML hooks and begin. The remaining scan hooks receive only a cursor, so an index type that needs its options mid-scan has to copy them into the cursor in begin.

Index Functions

An index function must be deterministic and must return REAL. Build one and hold it in a static const — the profile stores a reference to it, so its address has to outlive registration:
A function may declare at most eight parameters, the value of VEF_INDEX_PROFILE_FN_MAX_ARGS. The server checks the return type and the arity at INSTALL EXTENSION rather than at first use.

Index Profiles

A profile names the custom type, the index type, and the functions with their ids. for_type() takes the type name string — the same constant passed to make_type — not the type descriptor. with_function() and with_helper() take a built index function, not a name:
Ids must be unique within each list, and the two lists are numbered independently — with_function(1, ...) and with_helper(1, ...) do not collide. A repeated id inside one list is rejected at INSTALL EXTENSION. .default_for_type(true) marks this profile as the one to use when a statement names no profile. It applies per type and index type pair, and defaults to false. vsql_vector marks hnsw_l2, which is why an index created without a profile records hnsw_l2. .ordering() records the scan directions the profile supports, as NONE, ASC, DESC, or ASC | DESC. The server stores the value and nothing reads it. Only an ascending ordering is ever planned, so a profile that declares DESC alone cannot serve a query. A hook calls a bound function through index.profile() or a bound helper through index.helper(). Both take the key column position, the id the profile gave the function, a pointer to the result, and the column values as vef_storage_col_data_t arguments. index.helper_fn_name() reports the registered name of a bound helper instead of calling it, which lets an inner loop resolve its own native implementation once rather than dispatching per call:

Putting It Together

A skeleton, with the hook bodies, the options struct and the context left out:
Both capability objects must be static — the SDK holds pointers into them — and each must be passed to .with() exactly once. One capability object can carry several structures: chain .index_type() or .index_profile() per structure.

The SQL Syntax

A custom index is selected with USING EXTENDED, and the column may name the profile to use:
The indexed column must be a custom type some installed extension registers, and when the DDL names a profile that profile must be declared for that column’s type. A plain column is refused with ERROR 3219 (HY000): No default index profile found: column '<name>' is not a custom type. A partitioned table is refused with ERROR 3219 (HY000): InnoDB: Custom index is not supported on partitioned tables. The clause works inline in CREATE TABLE, and in CREATE INDEX and ALTER TABLE ADD INDEX. Both the profile and the index type may be qualified with the extension name, which is how you disambiguate two extensions offering the same name. When no profile is named, the server uses the profile that declared default_for_type(true) for that type and index type. Every statement in this section needs vsql_allow_preview_extensions set to ON, at startup or with SET PERSIST. Without it the statement is refused:
Using vsql_vector as the example, turn the setting on and install the extension first:
Dropping one is ordinary: DROP INDEX, ALTER TABLE ... DROP INDEX, or DROP TABLE, which removes the index metadata with the table. A custom index and the rows it holds survive a server restart.

Reading and Writing Through a Custom Index

Rows go in with INSERT, and the index is maintained as they do. Creating the index on a table that already holds rows works too, and builds the index over them. The build runs with the table exclusively locked rather than as an online DDL — a custom index always takes the offline build path, regardless of any ALGORITHM clause, because nothing yet replays concurrent writes into one. One query shape is served by a custom index: ORDER BY <distance>(<column>, <reference>) LIMIT K as the only ordering term, ascending, with a constant <reference>. The index must be single-column, its type must declare Index::Support::KNN, and its bound profile must supply the distance function. The column and the reference can be given in either order. A query that misses any of these conditions runs as a table scan and a sort, with no warning. EXPLAIN names the access:
The server does not filter the index’s own hits by the reading transaction’s view. It resolves each hit to its row, skips the rows that view cannot see, and takes the next candidate — so begin should hand back its whole search pool rather than exactly limit entries, or a concurrent write leaves the query short of K rows. An UPDATE that changes an indexed column is refused:
An UPDATE that changes the primary key draws the same error, as does a REPLACE or an INSERT ... ON DUPLICATE KEY UPDATE that hits an existing row. An UPDATE that touches only unindexed columns succeeds. An ALTER TABLE that changes the primary key of a table with a custom index is refused regardless of ALGORITHM:
Adding a column, or modifying a column other than the primary key, leaves the table’s existing custom index in place. Forcing a table rebuild with ALGORITHM=COPY or ALGORITHM=INPLACE also leaves the index in place, and a rebuilt index survives a restart.

What the Server Reports

SHOW CREATE TABLE renders a custom index’s bound profile, index type and any WITH parameters:
The profile and the index type are always backtick-quoted extension.name identifiers, never a string. Both the key and the value of a WITH parameter are lowercased and sorted alphabetically by key — the same normalization described under Index Options above — so a key written M renders as m. The profile is always shown, even when the DDL never named one and the server resolved it to the type’s default: the persisted metadata records only the resolved name. The rendering round-trips, so replaying a captured SHOW CREATE TABLE string reproduces an index that renders identically. INFORMATION_SCHEMA.STATISTICS.INDEX_TYPE and SHOW INDEX report the index-type name passed to USING EXTENDED(...), uppercased, in place of the storage engine’s own algorithm name. The value carries no extension qualifier, so two extensions offering the same index-type name are indistinguishable in this column. A plain B-tree index on the same table still reports BTREE:
After DROP INDEX idx_a ON t2, its row is gone from this query too.

Where the Metadata Lives

The server records each custom index in villagesql.custom_indexes, and its key columns — with the profile bound to each — in villagesql.custom_index_columns. Both survive a restart. Neither is readable from SQL, including as root:
Treat them as server-internal. SHOW CREATE TABLE and INFORMATION_SCHEMA.STATISTICS are the supported way to inspect a custom index from a client.