diff --git a/AGENTS.md b/AGENTS.md index d3e9d83..6990575 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ The PostgreSQL implementation of `@antelopejs/interface-database` and `@antelope ## Versioning -- Both implemented interfaces stay capped below the next minor (`interface-database` `>=0.3.1 <0.4.0`, `interface-database-catalog` `>=0.0.3 <0.1.0`), because a new minor can add required handlers. `pnpm lint` enforces it with `antelopejs-check-interface-ranges`. +- Both implemented interfaces stay capped below the next minor (`interface-database` `>=0.3.3 <0.4.0`, `interface-database-catalog` `>=0.0.3 <0.1.0`), because a new minor can add required handlers. `pnpm lint` enforces it with `antelopejs-check-interface-ranges`. - When a dependency range changes, update the compatibility table in `README.md` too. - A release that needs action from users gets an `### Upgrade notes` section in `CHANGELOG.md`, above the generated entries. diff --git a/README.md b/README.md index 60c8497..15aa31a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ This repository documents what is specific to the PostgreSQL backend: | Package | Supported | Tested with | | ---------------------------------------- | ---------------- | ----------- | | `@antelopejs/interface-core` | `>=0.1.1 <1.0.0` | `0.4.0` | -| `@antelopejs/interface-database` | `>=0.3.1 <0.4.0` | `0.3.1` | +| `@antelopejs/interface-database` | `>=0.3.3 <0.4.0` | `0.3.3` | | `@antelopejs/interface-database-catalog` | `>=0.0.3 <0.1.0` | `0.0.3` | diff --git a/docs/errors.md b/docs/errors.md index d00fc3d..750b3c6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -40,7 +40,8 @@ A cursor holds a pooled connection for as long as it is open, so the module recl | Two fields that truncate to the same column name | `Fields '{a}' and '{b}' on '{table}' both map to column '{column}'; rename one` | | Two tables or indexes that truncate to the same name | `The physical name '{name}' is claimed by both {a} and {b}; rename one` | | Declared a `unique` `multi` index over an array field | `Index '{indexId}' on table '{table}' is unique and multi over an array field, whose elements PostgreSQL cannot hold unique; drop one of the two flags` | -| Indexed a reserved column | `Index '{indexId}' keys on '{field}', which is a reserved column the adapter owns; index a declared field instead` | +| Indexed a reserved column | `Index '{indexId}' keys on '{field}', which reads the reserved column '{column}' the adapter owns; index a declared field instead` | +| Indexed a path below a declared value with no members | `Index '{indexId}' keys on '{field}', but '{owner}' is declared as a {type} with no members; index a declared field instead` | | Changed a declared type under `migrations: "safe"` | `Column '{name}' on {table} is {actual} but the schema declares {declared}; set migrations: "destructive" to alter it` | | Pointed the adapter at a pre-existing table it did not create | `Table {target} has no '{column}' column and one cannot be added without a default, so the table was not created by this adapter; rename it or drop it` | diff --git a/docs/storage.md b/docs/storage.md index 75eacea..f6fc6f1 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -62,6 +62,7 @@ A declared index becomes a real index, and the operations that can use one do: - **`crossInstance: true` adds an unprefixed twin.** The adapter also maintains `___g` over the same keys without `_instance`, and that twin serves a `CROSS_INSTANCE` read of the index. Without the flag, such a read stays correct but cannot use the index well. The first `getAll`, `between` or `orderBy` that names the index under `CROSS_INSTANCE` logs one warning per index per registration. A plain `filter` never warns. - **`unique: true` makes the `_instance`-prefixed index a `UNIQUE` one.** The key is unique within each instance, and two instances may share it. The `__g` twin of a `crossInstance` index is never unique. Rows that lack a field of the key hold NULL there and never conflict. Registration refuses a unique `multi` index over an array field, because GIN cannot be unique. When a schema declares a unique index, its writes wait for its index phase, so no write can store a duplicate before the index exists. Reads never wait. A write that the index refuses throws `DuplicateKeyError`. See [Duplicate keys](./errors.md#duplicate-keys). - **`multi` maps to GIN** over a single field backed by `jsonb`, which lets an index serve `@>`, `?`, `?|` and `?&`. A GIN index is keyed on the column alone, with no leading `_instance`. It already serves both kinds of read, so `crossInstance` changes nothing for it. `multi` on a scalar field falls back to btree instead of failing. +- **A dotted name is a nested path.** An index field, an `orderBy`, `getAll` or `between` name, and the other key of a `lookup` read `title.fr` as the `fr` member of `title`, unless the table declares a field named `title.fr` itself. The path may start at a declared field or at an undeclared key, as in `_internal.data.title.fr`. The index keys on the same expression the read compiles to, so it serves that read. A `date` member below a declared field is the exception: PostgreSQL cannot index its typed read, so the index keys on the stored JSON member. It still enforces `unique`, and it serves no read. - **These operations reach an index:** `filter` and `orderBy` on a declared field, `get`, `getAll`, `between`, a `joinInner` predicate, and the local key of a `lookup`. The local key reaches the index also when the looked-up query only plucked its columns, because the adapter pushes the correlation under that projection onto the column itself. A projection that computed its members is not pushed under, and the lateral join then scans the other table once per row of the left. Declaring a field is therefore the most useful thing you can do for query performance. An undeclared key lives in `_extra`, where it has no declared type to compare by and no index to serve it. diff --git a/package.json b/package.json index 7dca949..2798173 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "dependencies": { "@antelopejs/interface-core": ">=0.1.1 <1.0.0", - "@antelopejs/interface-database": ">=0.3.1 <0.4.0", + "@antelopejs/interface-database": ">=0.3.3 <0.4.0", "@antelopejs/interface-database-catalog": ">=0.0.3 <0.1.0", "pg": "^8.22.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc9260d..81c4ab8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: '>=0.1.1 <1.0.0' version: 0.4.0 '@antelopejs/interface-database': - specifier: '>=0.3.1 <0.4.0' - version: 0.3.1(@antelopejs/interface-core@0.4.0) + specifier: '>=0.3.3 <0.4.0' + version: 0.3.3(@antelopejs/interface-core@0.4.0) '@antelopejs/interface-database-catalog': specifier: '>=0.0.3 <0.1.0' version: 0.0.3(@antelopejs/interface-core@0.4.0) @@ -94,8 +94,8 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.2.4 <1.0.0' - '@antelopejs/interface-database@0.3.1': - resolution: {integrity: sha512-UNROciHOa5537lis/cUMTgr2J6bvYCyXrSdiIqdvG+CV9OZzf2TeKR6U5SBTYTjtnq47bIlVzFfz7gjOP4gmSA==} + '@antelopejs/interface-database@0.3.3': + resolution: {integrity: sha512-4NKCw+qc2BmisdPmh1i9ts6RHR6wOQz/TCnJtuwP8+a32PqTv2ZVZyHlLUX9RceKExqCQP6z90yXjtuqskRQVA==} peerDependencies: '@antelopejs/interface-core': '>=0.2.4 <1.0.0' @@ -2979,7 +2979,7 @@ snapshots: dependencies: '@antelopejs/interface-core': 0.4.0 - '@antelopejs/interface-database@0.3.1(@antelopejs/interface-core@0.4.0)': + '@antelopejs/interface-database@0.3.3(@antelopejs/interface-core@0.4.0)': dependencies: '@antelopejs/interface-core': 0.4.0 diff --git a/src/implementations/database/ddl.ts b/src/implementations/database/ddl.ts index 6dfdd3c..bf3fc01 100644 --- a/src/implementations/database/ddl.ts +++ b/src/implementations/database/ddl.ts @@ -4,13 +4,22 @@ import type { TableDefinition, } from "@antelopejs/interface-database/schema"; -import { JsonKey, QuoteIdentifier, QuoteLiteral } from "./sql"; +import { JSON_NULL, JsonKey, QuoteIdentifier, QuoteLiteral } from "./sql"; +import { + type ColumnAddress, + ColumnRowSource, + NamedField, + PATH_SEPARATOR, +} from "./row-source"; import { IsJsonBacked, + type KeyTypes, + KeyTypesOf, OPAQUE, PhysicalType, type SqlType, TEXT, + TIMESTAMP, } from "./types"; import { EXTRA_COLUMN, @@ -133,22 +142,71 @@ export function ColumnDdl(column: ColumnPlan): string { return parts.join(" "); } -const RESERVED_INDEX_KEYS = new Map([[ID_COLUMN, QuoteIdentifier(ID_COLUMN)]]); +/** The table's own columns, unqualified, as an index expression names them. */ +const IndexedColumns: ColumnAddress = (column, type) => ({ + sql: QuoteIdentifier(column), + type, +}); -function IndexKey(indexId: string, field: string, spec?: FieldSpec): string { - if (spec) { - return QuoteIdentifier(spec.column); +/** + * Refuses a path through a declared value that has no members, such as `name.fr` over a `string`. + * Its read is always NULL, and it reads through `to_jsonb`, which PostgreSQL refuses in an index + * expression, so the build would fail and stop the schema's index phase. + */ +function AssertPathHasMembers( + indexId: string, + field: string, + row: ColumnRowSource, +): void { + const segments = field.split(PATH_SEPARATOR); + let keys: KeyTypes | undefined = row.keyTypes(); + for (const [depth, segment] of segments.slice(0, -1).entries()) { + if (!keys || !Object.hasOwn(keys, segment)) { + return; + } + const type = keys[segment]; + if (!IsJsonBacked(type)) { + const owner = segments.slice(0, depth + 1).join(PATH_SEPARATOR); + throw new Error( + `Index '${indexId}' keys on '${field}', but '${owner}' is declared as a ${PhysicalType(type)} with no members; index a declared field instead`, + ); + } + keys = KeyTypesOf(type); } - const reserved = RESERVED_INDEX_KEYS.get(field); - if (reserved !== undefined) { - return reserved; +} + +/** + * An index key, spelled as the reads it serves spell the field: the same {@link NamedField} over + * the table's columns, so `orderBy`, `getAll`, `between` and `lookup` compile to its expression. + * + * A date member below a declared field is the exception. Its read parses text into `timestamptz`, + * which PostgreSQL refuses in an index expression, and a failed build stops the schema's index + * phase. Its key is the stored JSON member instead, a JSON null read as absent as the typed read + * does: the index builds and keeps `unique`, and serves no read. + */ +function IndexKey( + indexId: string, + field: string, + row: ColumnRowSource, + isDeclared: boolean, +): string { + if (isDeclared) { + return NamedField(row, field).sql; } - if (RESERVED_COLUMNS.includes(field)) { + const [head] = field.split(PATH_SEPARATOR); + if (field !== ID_COLUMN && RESERVED_COLUMNS.includes(head)) { throw new Error( - `Index '${indexId}' keys on '${field}', which is a reserved column the adapter owns; index a declared field instead`, + `Index '${indexId}' keys on '${field}', which reads the reserved column '${head}' the adapter owns; index a declared field instead`, ); } - return JsonKey(QuoteIdentifier(EXTRA_COLUMN), field); + AssertPathHasMembers(indexId, field, row); + const read = NamedField(row, field); + const cut = field.lastIndexOf(PATH_SEPARATOR); + if (read.type.kind !== TIMESTAMP.kind || cut < 0) { + return read.sql; + } + const parent = NamedField(row, field.slice(0, cut)); + return `NULLIF(${JsonKey(parent.sql, field.slice(cut + 1))}, ${JSON_NULL})`; } /** @@ -208,8 +266,9 @@ function BuildIndexPlans( ): IndexPlan[] { const fields = IndexFields(indexId, index); const specs = IndexSpecs(fields, model); + const row = new ColumnRowSource(IndexedColumns, model); const keys = fields.map((field, position) => - IndexKey(indexId, field, specs[position]), + IndexKey(indexId, field, row, specs[position] !== undefined), ); const name = IndexName(tableId, indexId); const isUnique = index.unique === true; diff --git a/src/implementations/database/relation.ts b/src/implementations/database/relation.ts index 50dba05..b33497c 100644 --- a/src/implementations/database/relation.ts +++ b/src/implementations/database/relation.ts @@ -11,6 +11,7 @@ import { ExternalRowSource, JsonRowSource, LoggedColumns, + NamedField, type RowSource, } from "./row-source"; @@ -160,6 +161,8 @@ export function RequireOrigin(relation: Relation): TableOrigin { * same way: a second expansion would sort by one field list and match against another, and the * btree scan would quietly stop being served with nothing failing. A relation that no longer knows * its table can only read the name as a field, which is what keeps `map(…).orderBy("age")` working. + * Each field is a {@link NamedField}, the reading the index DDL keys on, so `title.fr` sorts by the + * nested value and its index serves that sort. * * Each column keeps the field it was expanded from, because a compound index is also a *document* * to a `group`'s mapper and its member names are the index's own. Reading them beside the columns @@ -172,7 +175,7 @@ export function IndexColumns( const fields = relation.origin ? relation.origin.index(name) : [name]; return fields.map((field) => ({ name: field, - ...relation.source.field(field), + ...NamedField(relation.source, field), })); } diff --git a/src/implementations/database/row-source.ts b/src/implementations/database/row-source.ts index 6c2e884..9df0d31 100644 --- a/src/implementations/database/row-source.ts +++ b/src/implementations/database/row-source.ts @@ -104,6 +104,26 @@ export function BuildDocument( }; } +/** What separates the segments of a dotted name, `title.fr`, that {@link NamedField} reads. */ +export const PATH_SEPARATOR = "."; + +/** + * Reads a field by the name an index, a sort, a scan or a lookup's other key takes: a member by + * its exact name where the row declares one, and otherwise a dotted name as a path below its first + * segment. So `title.fr` reads the `fr` member of `title`, and the index over it holds that value. + */ +export function NamedField(source: RowSource, name: string): Compiled { + const [head, ...rest] = name.split(PATH_SEPARATOR); + const declared = source.keyTypes(); + if (rest.length === 0 || (declared && Object.hasOwn(declared, name))) { + return source.field(name); + } + return rest.reduce( + (value, segment) => new JsonRowSource(value).field(segment), + source.field(head), + ); +} + /** Names a key in the message {@link Defaulted} raises for a default the column cannot hold. */ function KeySubject(name: string): string { return `The default for key '${name}'`; diff --git a/src/implementations/database/stages-join.ts b/src/implementations/database/stages-join.ts index 0d92149..908708d 100644 --- a/src/implementations/database/stages-join.ts +++ b/src/implementations/database/stages-join.ts @@ -8,7 +8,7 @@ import { UnifyOperands } from "./coerce"; import { DecodeFunction, RecordedLambda } from "./value"; import { AsJson, Predicate } from "./readings"; import type { DecodingContext } from "./context"; -import { NullableRowSource, type RowSource } from "./row-source"; +import { NamedField, NullableRowSource, type RowSource } from "./row-source"; import type { PipelineStage, PipelineStageMap } from "./pipeline"; import { FIRST_OPERAND, RequireOperand, SECOND_OPERAND } from "./stage"; import { @@ -255,6 +255,7 @@ const PUSHDOWN_BLOCKERS: readonly RelationFlag[] = WHERE_BLOCKERS.filter( /** * Pushes the correlation onto the looked-up relation, comparing two declared columns. * Two columns of one kind take no cast; a scalar key against an array column takes containment. + * The other key is a {@link NamedField}, so `title.fr` matches the nested value and its index. */ function Correlate( right: Relation, @@ -263,7 +264,7 @@ function Correlate( local: Compiled, form: LookupForm, ): void { - const other = source.field(otherKey); + const other = NamedField(source, otherKey); if (IsArray(other.type) && !IsArray(local.type)) { right.where.push(`(${other.sql} ${CONTAINS} ${AsJson(local).sql})`); return; diff --git a/src/test/nested-paths.test.ts b/src/test/nested-paths.test.ts new file mode 100644 index 0000000..451f892 --- /dev/null +++ b/src/test/nested-paths.test.ts @@ -0,0 +1,288 @@ +import { expect } from "chai"; +import { + IsDuplicateKeyError, + type DuplicateKeyError, + type Stream, +} from "@antelopejs/interface-database"; +import { + Schema, + type SchemaDefinition, +} from "@antelopejs/interface-database/schema"; + +import { AcquireClient, Query } from "../connection"; +import { Render } from "../implementations/database/render"; +import { QuoteIdentifier } from "../implementations/database/sql"; +import { BuildRoot } from "../implementations/database/selection"; +import { DecodingContext } from "../implementations/database/context"; +import { CompileSubquery } from "../implementations/database/subquery"; +import { CompileExpression } from "../implementations/database/expression"; +import { SchemaName } from "../implementations/database/identifier"; +import { + ApplyStages, + CompileRelation, +} from "../implementations/database/pipeline"; +import { WaitForIndexes } from "../implementations/database/schema"; + +interface Localized { + fr: string; + en: string; +} + +interface Book { + _id: string; + title: Localized; + _internal: { data: { title: Localized } }; + "title.fr"?: string; + "_internal.data.title.fr"?: string; +} + +interface Review { + _id: string; + bookTitle: string; +} + +interface Event { + _id: string; + meta: { at: Date | null }; + "meta.at"?: Date; +} + +interface Tables { + books: Book; + booksIndexed: Book; + reviews: Review; + events: Event; +} + +type BookTable = "books" | "booksIndexed"; + +const SCHEMA_ID = "nested_paths"; +const INSTANCE = "one"; + +const BOOK_FIELDS = { title: { fr: "string", en: "string" } }; + +const DEFINITION: SchemaDefinition = { + books: { fields: BOOK_FIELDS, indexes: {} }, + booksIndexed: { + fields: BOOK_FIELDS, + indexes: { + titleFr: { fields: ["title.fr"], unique: true }, + internalTitleFr: { fields: ["_internal.data.title.fr"], unique: true }, + }, + }, + reviews: { fields: { bookTitle: "string" }, indexes: {} }, + events: { + fields: { meta: { at: "date" } }, + indexes: { metaAt: { fields: ["meta.at"], unique: true } }, + }, +}; + +const FRENCH = ["delta", "alpha", "echo", "charlie", "bravo"]; +const ENGLISH = ["a", "b", "c", "d", "e"]; + +function MakeBook(fr: string, position: number): Book { + const title = { fr, en: ENGLISH[position] }; + return { _id: `book-${position}`, title, _internal: { data: { title } } }; +} + +const BOOKS = FRENCH.map(MakeBook); +const BY_FRENCH = [...BOOKS] + .sort((left, right) => left.title.fr.localeCompare(right.title.fr)) + .map((book) => book._id); + +const REVIEWED = ["charlie", "alpha"]; + +const EVENT_TIMES = [3000, 1000, 2000]; +const EVENTS: Event[] = EVENT_TIMES.map((time, n) => ({ + _id: `event-${n}`, + meta: { at: new Date(time) }, +})); + +let schema: Schema; + +function Table(table: K) { + return schema.instance(INSTANCE).table(table); +} + +async function Ids(stream: Stream): Promise { + return (await stream).map((book) => book._id); +} + +async function LookedUp( + table: BookTable, + otherKey: "title.fr" | "_internal.data.title.fr", +): Promise<(string | undefined)[]> { + const reviews = await Table("reviews") + .orderBy("_id") + .lookup(Table(table), "bookTitle", otherKey); + return reviews.map((review) => review.bookTitle?._id); +} + +function Compile(stream: Stream) { + const context = new DecodingContext({ + expression: CompileExpression, + subquery: CompileSubquery, + relation: CompileRelation, + }); + const root = BuildRoot(stream.build(), context); + const relation = ApplyStages(root.relation, root.rest, context); + return { sql: Render(relation), params: context.params.all() }; +} + +async function PlanOverIndexes(stream: Stream): Promise { + const compiled = Compile(stream); + const client = await AcquireClient(); + try { + await client.query("BEGIN"); + for (const method of ["seqscan", "bitmapscan", "sort"]) { + await client.query(`SET LOCAL enable_${method} = off`); + } + const plan = await client.query<{ "QUERY PLAN": string }>( + `EXPLAIN ${compiled.sql}`, + compiled.params, + ); + return plan.rows.map((row) => row["QUERY PLAN"]).join("\n"); + } finally { + await client.query("ROLLBACK"); + client.release(); + } +} + +async function InsertRejected(book: Book): Promise { + try { + await Table("booksIndexed").insert(book); + } catch (error) { + if (IsDuplicateKeyError(error)) { + return error; + } + throw error; + } + return expect.fail(`the insert of ${book._id} was not refused`); +} + +describe("dotted names read nested paths", () => { + before(async () => { + await Query( + `DROP SCHEMA IF EXISTS ${QuoteIdentifier(SchemaName(SCHEMA_ID))} CASCADE`, + ); + schema = new Schema(SCHEMA_ID, DEFINITION); + await WaitForIndexes(SCHEMA_ID); + await schema.createInstance(INSTANCE); + await Table("books").insert(BOOKS); + await Table("booksIndexed").insert(BOOKS); + await Table("events").insert(EVENTS); + await Table("reviews").insert( + REVIEWED.map((bookTitle, n) => ({ _id: `review-${n}`, bookTitle })), + ); + }); + + after(async () => { + await schema.destroyInstance(INSTANCE); + }); + + it("orderBy sorts by a path below a declared field", async () => { + expect(await Ids(Table("books").orderBy("title.fr"))).to.deep.equal( + BY_FRENCH, + ); + expect(await Ids(Table("books").orderBy("title.fr", "desc"))).to.deep.equal( + [...BY_FRENCH].reverse(), + ); + }); + + it("orderBy sorts by a path below an undeclared key", async () => { + expect( + await Ids(Table("books").orderBy("_internal.data.title.fr")), + ).to.deep.equal(BY_FRENCH); + }); + + it("orderBy by an index over a path sorts by that path", async () => { + expect(await Ids(Table("booksIndexed").orderBy("titleFr"))).to.deep.equal( + BY_FRENCH, + ); + expect( + await Ids(Table("booksIndexed").orderBy("internalTitleFr")), + ).to.deep.equal(BY_FRENCH); + }); + + it("getAll by an index over a path and between by a path match that path", async () => { + expect( + await Ids(Table("booksIndexed").getAll("charlie", "titleFr")), + ).to.deep.equal(["book-3"]); + expect( + await Ids( + Table("books") + .between("_internal.data.title.fr", "bravo", "delta") + .orderBy("_id"), + ), + ).to.deep.equal(["book-3", "book-4"]); + }); + + for (const table of ["books", "booksIndexed"] as const) { + it(`lookup finds the ${table} row by a path below a declared field`, async () => { + expect(await LookedUp(table, "title.fr")).to.deep.equal([ + "book-3", + "book-1", + ]); + }); + + it(`lookup finds the ${table} row by a path below an undeclared key`, async () => { + expect(await LookedUp(table, "_internal.data.title.fr")).to.deep.equal([ + "book-3", + "book-1", + ]); + }); + } + + it("a unique index over a path refuses a second row with the same nested value", async () => { + const declared = await InsertRejected({ + ...MakeBook("alpha", 9), + _internal: { data: { title: { fr: "other", en: "x" } } }, + }); + expect(declared.index).to.equal("titleFr"); + const undeclared = await InsertRejected({ + ...MakeBook("other", 9), + _internal: { data: { title: { fr: "alpha", en: "x" } } }, + }); + expect(undeclared.index).to.equal("internalTitleFr"); + }); + + it("an index over a date below a declared field builds, refuses a duplicate, and the sort reads dates", async () => { + const sorted = await Table("events").orderBy("meta.at"); + expect(sorted.map((event) => event._id)).to.deep.equal([ + "event-1", + "event-2", + "event-0", + ]); + let refused: unknown; + try { + await Table("events").insert({ ...EVENTS[0], _id: "event-copy" }); + } catch (error) { + refused = error; + } + expect(IsDuplicateKeyError(refused) && refused.index).to.equal("metaAt"); + }); + + it("an index over a date below a declared field holds a stored null as absent", async () => { + const unset = ["event-null-0", "event-null-1"].map((_id) => ({ + _id, + meta: { at: null }, + })); + await Table("events").insert(unset); + expect( + await Table("events").getAll(["event-null-0", "event-null-1"]).count(), + ).to.equal(2); + }); + + it("the index over a path serves a sort and a match by that path", async () => { + const sorted = await PlanOverIndexes( + Table("booksIndexed").orderBy("titleFr"), + ); + expect(sorted).to.match(/Index Scan using "?booksIndexed_titleFr"?/i); + const matched = await PlanOverIndexes( + Table("booksIndexed").getAll("charlie", "internalTitleFr"), + ); + expect(matched).to.match( + /Index (Only )?Scan using "?booksIndexed_internalTitleFr"?/i, + ); + }); +}); diff --git a/src/test/unit/ddl.spec.ts b/src/test/unit/ddl.spec.ts index 751e1f6..7815822 100644 --- a/src/test/unit/ddl.spec.ts +++ b/src/test/unit/ddl.spec.ts @@ -116,6 +116,35 @@ describe("BuildTablePlan", () => { } }); + it("refuses an index over a path below a declared value with no members", () => { + const cases: [TableDefinition["fields"], string, string][] = [ + [{ name: "string" }, "name.fr", "'name' is declared as a text"], + [ + { meta: { at: "date" } }, + "meta.at.x", + "'meta.at' is declared as a timestamptz", + ], + ]; + for (const [fields, field, owner] of cases) { + const build = () => + BuildOne({ fields, indexes: { path: { fields: [field] } } }); + expect(build).to.throw(owner); + } + }); + + it("keys a declared dotted field on its own column, even when its first segment is reserved", () => { + const index = BuildOne({ + fields: { "dot.ted": "string", dot: { ted: "string" } }, + indexes: { dotted: { fields: ["dot.ted"] } }, + }); + expect(index.keys).to.deep.equal([`"_instance"`, `"dot.ted"`]); + const reservedHead = BuildOne({ + fields: { "_id.x": "string" }, + indexes: { idx: { fields: ["_id.x"] } }, + }); + expect(reservedHead.keys).to.deep.equal([`"_instance"`, `"_id.x"`]); + }); + it("falls back to the index id when fields is explicitly empty", () => { const index = BuildOne({ fields: { car: "string" },