Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/1.query_types/5.stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Stream<T> extends Query<T[]> {
lookup<U, TK extends keyof T & string>(
right: Stream<U>,
localKey: TK,
otherKey: keyof U & string,
otherKey: FieldPath<U>,
): Stream<Omit<T, TK> & Record<TK, T[TK] extends any[] ? U[] : U>>;

// Grouping
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion docs/1.query_types/8.datum.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Datum<T> extends Query<T> {
lookup<U = any, TK extends keyof T & string = keyof T & string>(
other: Stream<U>,
localKey: TK,
otherKey: keyof U & string,
otherKey: FieldPath<U>,
): Datum<Omit<T, TK> & Record<TK, T[TK] extends any[] ? U[] : U>>;
pluck<K extends keyof NonNullable<T> & string>(
...fields: K[]
Expand Down
20 changes: 20 additions & 0 deletions docs/2.operations/5.indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 35 additions & 1 deletion docs/2.operations/7.lookup.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mode: how-to
| ---------- | ------------------ | --------------------------------------------------------- |
| `other` | `Stream<U>` | 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<U>` | The field in the other table to match against |

`Stream.lookup()` names its first parameter `right`. `Datum.lookup()` names it `other`.

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion guardrails.suppressions.json
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/staged-query/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ export type OperandValue<V> = 0 extends 1 & V
? { [K in keyof V]: OperandValue<V[K]> }
: 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<T> = (keyof T & string) | `${keyof T & string}.${string}`;

/** A document as `insert`, `update` and `replace` record it. */
export interface WriteDocument {
[field: string]: WriteValue;
Expand Down
7 changes: 4 additions & 3 deletions src/staged-query/datum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Stream } from "./stream";
import {
QueryStage,
type ExtractType,
type FieldPath,
type QueryValue,
type Value,
} from "./common";
Expand All @@ -25,7 +26,6 @@ export class Datum<T> extends Query<T> {
* T, so the same object is a valid Datum<U> 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<U>` 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<U>;
}

Expand Down Expand Up @@ -86,12 +86,13 @@ export class Datum<T> extends Query<T> {
*
* @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<U = any, TK extends keyof T & string = keyof T & string>(
other: Stream<U>,
localKey: TK,
otherKey: keyof U & string,
otherKey: FieldPath<U>,
) {
return this.stage(
Datum<Omit<T, TK> & Record<TK, T[TK] extends any[] ? U[] : U>>,
Expand Down
4 changes: 4 additions & 0 deletions src/staged-query/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
10 changes: 6 additions & 4 deletions src/staged-query/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
QueryStage,
type Changes,
type ExtractType,
type FieldPath,
type QueryValue,
type Value,
} from "./common";
Expand All @@ -26,7 +27,6 @@ export class Stream<T> extends Query<T[]> {
* T, so the same object is a valid Stream<U> 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<U>` 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<U>;
}

Expand Down Expand Up @@ -185,13 +185,14 @@ export class Stream<T> extends Query<T[]> {
*
* @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<U, TK extends keyof T & string>(
right: Stream<U>,
localKey: TK,
otherKey: keyof U & string,
otherKey: FieldPath<U>,
) {
return this.stage(
Stream<Omit<T, TK> & Record<TK, T[TK] extends any[] ? U[] : U>>,
Expand Down Expand Up @@ -232,7 +233,8 @@ export class Stream<T> extends Query<T[]> {
/**
* 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
*/
Expand Down
166 changes: 166 additions & 0 deletions src/tests/dotted_paths.test.ts
Original file line number Diff line number Diff line change
@@ -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<Article, "_id">[] = [
{
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", "desc").run();

expect(result.map((doc) => doc._id)).to.deep.equal([
articleKeys[0],
articleKeys[2],
articleKeys[3],
articleKeys[1],
]);
}

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");
}
Loading