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" },