From ea07b6f232b32c3d05f596d57eb2cf3f6955dd03 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Thu, 12 Nov 2026 17:55:56 +0000 Subject: [PATCH 1/3] feat(staged-query): accept a nested path as lookup otherKey Add FieldPath, a field of T or a dot-separated path starting at one of its fields, and use it for the otherKey of Stream.lookup and Datum.lookup. State in the TSDoc and the docs that a dotted lookup key, orderBy name, or index field is a nested path, never a literal top-level key. Drop two oxlint directives that no longer suppress anything and lower their ceiling. --- docs/1.query_types/5.stream.md | 4 ++-- docs/1.query_types/8.datum.md | 2 +- docs/2.operations/5.indexes.md | 20 +++++++++++++++++++ docs/2.operations/7.lookup.md | 36 +++++++++++++++++++++++++++++++++- guardrails.suppressions.json | 2 +- src/staged-query/common.ts | 11 +++++++++++ src/staged-query/datum.ts | 7 ++++--- src/staged-query/schema.ts | 4 ++++ src/staged-query/stream.ts | 10 ++++++---- 9 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/1.query_types/5.stream.md b/docs/1.query_types/5.stream.md index 9090112..5ef4af3 100644 --- a/docs/1.query_types/5.stream.md +++ b/docs/1.query_types/5.stream.md @@ -51,7 +51,7 @@ class Stream extends Query { lookup( right: Stream, localKey: TK, - otherKey: keyof U & string, + otherKey: FieldPath, ): Stream & Record>; // Grouping @@ -206,7 +206,7 @@ const statsByRole = await users.group("role", (stream, role) => ({ ### `orderBy()` -Sorts the stream by an index field, in ascending or descending order. +Sorts the stream by an index field, in ascending or descending order. The name is an index of the table, or else a field. A dotted name is a nested path, so `orderBy("title.fr")` sorts on `fr` inside `title`. ```typescript fragment const sorted = await users.orderBy("name", "asc"); diff --git a/docs/1.query_types/8.datum.md b/docs/1.query_types/8.datum.md index 5640183..e233bfa 100644 --- a/docs/1.query_types/8.datum.md +++ b/docs/1.query_types/8.datum.md @@ -24,7 +24,7 @@ class Datum extends Query { lookup( other: Stream, localKey: TK, - otherKey: keyof U & string, + otherKey: FieldPath, ): Datum & Record>; pluck & string>( ...fields: K[] diff --git a/docs/2.operations/5.indexes.md b/docs/2.operations/5.indexes.md index 402adf9..dc194ce 100644 --- a/docs/2.operations/5.indexes.md +++ b/docs/2.operations/5.indexes.md @@ -38,6 +38,24 @@ interface IndexDefinition { When you omit `fields`, the index name is the field name. For a compound index, list its fields in the `fields` array. +A dotted entry in `fields`, or a dotted index name without `fields`, is a nested path. `"title.fr"` indexes `fr` inside `title`, never a top-level key named `title.fr`. + +```typescript +import { Schema } from "@antelopejs/interface-database"; + +const schema = new Schema("catalog", { + articles: { + fields: { + title: { fr: "string", en: "string" }, + }, + indexes: { + title_fr: { fields: ["title.fr"] }, + "title.en": {}, + }, + }, +}); +``` + ## Use indexes for queries ### `getAll()` with an index @@ -72,6 +90,8 @@ const sorted = await users.orderBy("email", "asc"); const newest = await users.orderBy("createdAt", "desc"); ``` +`orderBy()` takes an index name, or else a field. A dotted field is a nested path, so `orderBy("meta.rank")` sorts on `rank` inside `meta` when no index has that name. + ## Compound indexes A compound index spans several fields. Define it with the `fields` array: diff --git a/docs/2.operations/7.lookup.md b/docs/2.operations/7.lookup.md index 95b515f..922f7ba 100644 --- a/docs/2.operations/7.lookup.md +++ b/docs/2.operations/7.lookup.md @@ -12,7 +12,7 @@ mode: how-to | ---------- | ------------------ | --------------------------------------------------------- | | `other` | `Stream` | The table containing the referenced documents | | `localKey` | `keyof T & string` | The field in the current document holding the foreign key | -| `otherKey` | `keyof U & string` | The field in the other table to match against | +| `otherKey` | `FieldPath` | The field in the other table to match against | `Stream.lookup()` names its first parameter `right`. `Datum.lookup()` names it `other`. @@ -94,6 +94,40 @@ for (const order of ordersWithProducts) { } ``` +## Match a nested key + +A dotted `otherKey` is a nested path in the other document. `"meta.sku"` matches the `sku` inside `meta`, never a top-level key named `meta.sku`. `localKey` stays a top-level field. + +```typescript +import { Schema } from "@antelopejs/interface-database"; + +interface OrderLine { + _id: string; + productRef: string; +} + +interface Product { + _id: string; + meta: { sku: string }; +} + +const catalogSchema = Schema.get<{ lines: OrderLine; products: Product }>( + "catalog", +); +if (!catalogSchema) throw new Error('Schema "catalog" is not defined'); + +const catalog = catalogSchema.instance(); + +const linesWithProducts = await catalog + .table("lines") + .lookup(catalog.table("products"), "productRef", "meta.sku"); + +// Result type: { _id: string, productRef: Product } +for (const line of linesWithProducts) { + console.log(line.productRef.meta.sku); +} +``` + ## Combine with other operations `lookup()` chains with filters, sorting, and the other stream operations: diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 0819544..ff130e7 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,7 +1,7 @@ { "rules": { "@typescript-eslint/no-unused-expressions": 1, - "anti-slop/no-chained-type-assertions": 3, + "anti-slop/no-chained-type-assertions": 1, "typescript/no-unnecessary-type-parameters": 1, "typescript/no-unused-vars": 1, "unicorn/no-thenable": 1 diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts index eab88a3..b242b32 100644 --- a/src/staged-query/common.ts +++ b/src/staged-query/common.ts @@ -79,6 +79,17 @@ export type OperandValue = 0 extends 1 & V ? { [K in keyof V]: OperandValue } : never; +/** + * A field of `T`, or a dot-separated path that starts at one of its fields. + * Each dot steps into a nested object, so `"title.fr"` reads `fr` inside + * `title`, never a top-level key named `title.fr`. + * + * The path after the first field is not checked against the field's type: a + * localized field is typed `string` while it stores a locale record, and a + * strict path type would reject `"title.fr"`. + */ +export type FieldPath = (keyof T & string) | `${keyof T & string}.${string}`; + /** A document as `insert`, `update` and `replace` record it. */ export interface WriteDocument { [field: string]: WriteValue; diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index 4f2adf8..c288063 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -4,6 +4,7 @@ import type { Stream } from "./stream"; import { QueryStage, type ExtractType, + type FieldPath, type QueryValue, type Value, } from "./common"; @@ -25,7 +26,6 @@ export class Datum extends Query { * T, so the same object is a valid Datum for every U. Whether U matches * what the query yields is the caller's claim, which nothing here checks. */ - // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- a single `as Datum` fails TS2352 because T and U are unrelated, and widening to StagedObject first is the refused route; the cast only relabels the type argument of the same object. return this as unknown as Datum; } @@ -86,12 +86,13 @@ export class Datum extends Query { * * @param other Other table * @param localKey Key in local object - * @param otherKey Key in other table + * @param otherKey Key in other table. A dotted key is a nested path in the + * other document */ public lookup( other: Stream, localKey: TK, - otherKey: keyof U & string, + otherKey: FieldPath, ) { return this.stage( Datum & Record>, diff --git a/src/staged-query/schema.ts b/src/staged-query/schema.ts index d9abc26..998331f 100644 --- a/src/staged-query/schema.ts +++ b/src/staged-query/schema.ts @@ -8,6 +8,10 @@ import { QueryStage, StagedObject } from "./common"; export interface IndexDefinition { /** * Fields to use for compound indexes + * + * A dotted field is a nested path: `"title.fr"` indexes `fr` inside `title`, + * not a top-level key named `title.fr`. When omitted, the index name is the + * field name and follows the same rule. */ fields?: string[]; diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index ad9f983..8a619cb 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -4,6 +4,7 @@ import { QueryStage, type Changes, type ExtractType, + type FieldPath, type QueryValue, type Value, } from "./common"; @@ -26,7 +27,6 @@ export class Stream extends Query { * T, so the same object is a valid Stream for every U. Whether U matches * what the query yields is the caller's claim, which nothing here checks. */ - // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- a single `as Stream` fails TS2352 because T and U are unrelated, and widening to StagedObject first is the refused route; the cast only relabels the type argument of the same object. return this as unknown as Stream; } @@ -185,13 +185,14 @@ export class Stream extends Query { * * @param right Stream containing the other documents * @param localKey Key in the local document to search with and replace - * @param otherKey Key in the other document to match against + * @param otherKey Key in the other document to match against. A dotted key is + * a nested path in the other document * @returns New stream */ public lookup( right: Stream, localKey: TK, - otherKey: keyof U & string, + otherKey: FieldPath, ) { return this.stage( Stream & Record>, @@ -232,7 +233,8 @@ export class Stream extends Query { /** * Sort the stream using the given index and direction * - * @param index Index to sort + * @param index Name of an index of the table, or else a field. A dotted name + * is a nested path * @param direction Sort direction * @returns New (sorted) stream */ From 3ef49585010ef85f4c29b8a806fb898af1eacbec Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Thu, 12 Nov 2026 17:55:59 +0000 Subject: [PATCH 2/3] test(conformance): cover dotted index fields, orderBy names and lookup keys --- src/tests/dotted_paths.test.ts | 166 +++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/tests/dotted_paths.test.ts diff --git a/src/tests/dotted_paths.test.ts b/src/tests/dotted_paths.test.ts new file mode 100644 index 0000000..0e03105 --- /dev/null +++ b/src/tests/dotted_paths.test.ts @@ -0,0 +1,166 @@ +import { expect } from "chai"; +import { Schema } from ".."; + +interface Article { + _id: string; + title: { fr: string; en: string }; + meta: { rank: number; sku: string }; +} + +interface ArticleOrder { + _id: string; + articleRef: string; +} + +const articlesName = "articles"; +const ordersName = "orders"; + +const schema = new Schema<{ + [articlesName]: Article; + [ordersName]: ArticleOrder; +}>("test-dotted-paths", { + [articlesName]: { + fields: { + title: { fr: "string", en: "string" }, + meta: { rank: "number", sku: "string" }, + }, + indexes: { + title_fr: { fields: ["title.fr"] }, + "title.en": {}, + "meta.sku": {}, + }, + }, + [ordersName]: { fields: { articleRef: "string" }, indexes: {} }, +}); + +const articles = schema.instance("default").table(articlesName); +const orders = schema.instance("default").table(ordersName); + +const articlesData: Omit[] = [ + { + title: { fr: "delta", en: "charlie" }, + meta: { rank: 3, sku: "SKU-A" }, + }, + { + title: { fr: "alpha", en: "delta" }, + meta: { rank: 1, sku: "SKU-B" }, + }, + { + title: { fr: "charlie", en: "bravo" }, + meta: { rank: 4, sku: "SKU-C" }, + }, + { + title: { fr: "bravo", en: "alpha" }, + meta: { rank: 2, sku: "SKU-D" }, + }, +]; + +const orderedSkus = ["SKU-C", "SKU-A", "SKU-D"]; + +let articleKeys: string[] = []; +let orderKeys: string[] = []; + +describe("Dotted Paths", () => { + before(async () => { + await schema.createInstance("default").run(); + await orders.delete().run(); + await articles.delete().run(); + }); + + after(async () => { + await orders.delete().run(); + await articles.delete().run(); + await schema.destroyInstance("default").run(); + }); + + it("Insert Test Data", InsertTestData); + it( + "getAll on an index whose fields entry is dotted finds the row by its nested value, not a literal top-level key", + GetAllOnDottedField, + ); + it( + "getAll on a dotted index name without fields finds the row by its nested value, not a literal top-level key", + GetAllOnDottedName, + ); + it( + "orderBy on an index with a dotted field sorts by the nested value, not in insertion order", + OrderByIndexWithDottedField, + ); + it( + "orderBy on a dotted name that is no index sorts by the nested value, not in insertion order", + OrderByDottedNameWithoutIndex, + ); + it( + "Stream lookup on a dotted otherKey matches the nested value, not a literal top-level key", + StreamLookupOnDottedKey, + ); + it( + "Datum lookup on a dotted otherKey matches the nested value, not a literal top-level key", + DatumLookupOnDottedKey, + ); +}); + +async function InsertTestData() { + articleKeys = await articles.insert(articlesData).run(); + expect(articleKeys).to.have.lengthOf(articlesData.length); + + orderKeys = await orders + .insert(orderedSkus.map((articleRef) => ({ articleRef }))) + .run(); + expect(orderKeys).to.have.lengthOf(orderedSkus.length); +} + +async function GetAllOnDottedField() { + const result = await articles.getAll("delta", "title_fr").run(); + + expect(result.map((doc) => doc._id)).to.deep.equal([articleKeys[0]]); +} + +async function GetAllOnDottedName() { + const result = await articles.getAll("delta", "title.en").run(); + + expect(result.map((doc) => doc._id)).to.deep.equal([articleKeys[1]]); +} + +async function OrderByIndexWithDottedField() { + const result = await articles.orderBy("title_fr", "asc").run(); + + expect(result.map((doc) => doc._id)).to.deep.equal([ + articleKeys[1], + articleKeys[3], + articleKeys[2], + articleKeys[0], + ]); +} + +async function OrderByDottedNameWithoutIndex() { + const result = await articles.orderBy("meta.rank", "desc").run(); + + expect(result.map((doc) => doc._id)).to.deep.equal([ + articleKeys[2], + articleKeys[0], + articleKeys[3], + articleKeys[1], + ]); +} + +async function StreamLookupOnDottedKey() { + const result = await orders.lookup(articles, "articleRef", "meta.sku").run(); + + expect(result).to.have.lengthOf(orderedSkus.length); + const expectedArticles = [articleKeys[2], articleKeys[0], articleKeys[3]]; + orderKeys.forEach((orderKey, index) => { + const doc = result.find((candidate) => candidate._id === orderKey); + expect(doc?.articleRef).to.have.property("_id", expectedArticles[index]); + }); +} + +async function DatumLookupOnDottedKey() { + const result = await orders + .get(orderKeys[1]) + .lookup(articles, "articleRef", "meta.sku") + .run(); + + expect(result.articleRef).to.have.property("_id", articleKeys[0]); + expect(result.articleRef.meta).to.have.property("sku", "SKU-A"); +} From 119aca4dfe150120ab093a3cfc43562303ca160f Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Thu, 12 Nov 2026 18:02:40 +0000 Subject: [PATCH 3/3] test(conformance): sort the dotted index case in descending order An unsorted MongoDB scan already returns the rows in ascending title.fr order, so the ascending case passed on a driver that sorts on a literal key. --- src/tests/dotted_paths.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests/dotted_paths.test.ts b/src/tests/dotted_paths.test.ts index 0e03105..59c0b13 100644 --- a/src/tests/dotted_paths.test.ts +++ b/src/tests/dotted_paths.test.ts @@ -123,13 +123,13 @@ async function GetAllOnDottedName() { } async function OrderByIndexWithDottedField() { - const result = await articles.orderBy("title_fr", "asc").run(); + const result = await articles.orderBy("title_fr", "desc").run(); expect(result.map((doc) => doc._id)).to.deep.equal([ - articleKeys[1], - articleKeys[3], - articleKeys[2], articleKeys[0], + articleKeys[2], + articleKeys[3], + articleKeys[1], ]); }