@leuduan/block 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -19
- package/dist/build.d.ts +2 -6
- package/dist/build.js +12 -2
- package/dist/descriptor.d.ts +6 -4
- package/dist/problems.js +9 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,16 +18,19 @@ literals (`e.literalString`, the typed date and time literals) and quoted identi
|
|
|
18
18
|
are escaped with GoogleSQL's backslash escapes.
|
|
19
19
|
|
|
20
20
|
It also owns datasets: `define` declares one (a table, or a query whose `build` receives a `BuildContext`) and
|
|
21
|
-
records its call site, `problemsOf` judges a definition, and `datasetBuilder` builds them.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
records its call site, `problemsOf` judges a definition, and `datasetBuilder` builds them. Every definition carries
|
|
22
|
+
its own `id`, a path of names from the outermost namespace to the dataset (`['analytics', 'events']`), so a dataset
|
|
23
|
+
keeps its name wherever its declaration lives; messages show it dotted, and no segment may contain a dot. A builder is
|
|
24
|
+
one session that builds each
|
|
25
|
+
(dataset, case) once: it refuses two datasets with one id, resolves parameter values from defaults and cases, keeps
|
|
26
|
+
one value per parameter name across inlined queries, inlines references and refuses cycles, and returns the result graph
|
|
24
27
|
(`BuiltDataset`: the query, its parameters, the results it referenced, the assets it loaded). A failure inside a
|
|
25
28
|
referenced dataset is rethrown at the `ctx.ref` call with the original as its `cause`.
|
|
26
29
|
|
|
27
30
|
It does not own table names, projects, asset files or what a query means. The builder takes them from the caller, with
|
|
28
|
-
no defaults: `
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
no defaults: `tableName` gives a table's fully-qualified name, `asset` loads an asset's SQL text. Policies over the
|
|
32
|
+
built SQL, such as a rule against handwritten table ids or references outside a catalog (the result graph lists
|
|
33
|
+
them), are the caller's too. Quoting is the caller's job as well: `from(string)` and `t.path(id, false)` emit the text as given, so use `t.path(id, true)` or
|
|
31
34
|
`e.ident(name)` for identifiers. `create_table` and the ANSI dialect (`dbox.block.ansi`) are not ported.
|
|
32
35
|
|
|
33
36
|
## Use
|
|
@@ -49,28 +52,19 @@ duplicates.format() // FROM `my-project.analytics.events` |> SELECT ...
|
|
|
49
52
|
Datasets, built standalone:
|
|
50
53
|
|
|
51
54
|
```ts
|
|
52
|
-
import { datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
|
|
55
|
+
import { Dataset, datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
|
|
53
56
|
|
|
54
|
-
const EVENTS = define({ kind: 'table',
|
|
57
|
+
const EVENTS = define({ kind: 'table', id: ['analytics', 'events'], description: 'Raw events.' })
|
|
55
58
|
const recent = define({
|
|
56
59
|
kind: 'query',
|
|
60
|
+
id: ['analytics', 'recent_events'],
|
|
57
61
|
description: 'Events since a day.',
|
|
58
|
-
grain: 'one row per event',
|
|
59
62
|
params: { day: { type: 'DATE', default: '2026-01-01' } },
|
|
60
63
|
build: (ctx) => pipe(ctx.ref(EVENTS), p.where(sql`day >= ${ctx.param('day')}`)),
|
|
61
64
|
})
|
|
62
65
|
|
|
63
|
-
const names = new Map([
|
|
64
|
-
[EVENTS, 'events'],
|
|
65
|
-
[recent, 'recent'],
|
|
66
|
-
])
|
|
67
66
|
const builder = datasetBuilder({
|
|
68
|
-
|
|
69
|
-
const name = names.get(dataset)
|
|
70
|
-
if (name === undefined) throw new Error('not a dataset of this catalog')
|
|
71
|
-
return name
|
|
72
|
-
},
|
|
73
|
-
tableName: () => 'my-project.analytics.events',
|
|
67
|
+
tableName: (dataset) => `my-project.${Dataset.definitionOf(dataset).id.join('.')}`,
|
|
74
68
|
asset: (file) => {
|
|
75
69
|
throw new Error(`no assets here: ${file}`)
|
|
76
70
|
},
|
package/dist/build.d.ts
CHANGED
|
@@ -8,11 +8,6 @@ export interface Parameter {
|
|
|
8
8
|
value: ParamValue;
|
|
9
9
|
}
|
|
10
10
|
export interface BuildOptions {
|
|
11
|
-
/**
|
|
12
|
-
* The caller's id for a dataset, used in messages. It throws for a dataset the caller does not know, which refuses
|
|
13
|
-
* a `ctx.ref` to it.
|
|
14
|
-
*/
|
|
15
|
-
idOf(dataset: Dataset): string;
|
|
16
11
|
/** The fully-qualified name of a table dataset. */
|
|
17
12
|
tableName(dataset: Dataset): string;
|
|
18
13
|
/** The SQL text of the asset `file` that `dataset`'s build asks for. */
|
|
@@ -35,7 +30,8 @@ export interface BuiltDataset {
|
|
|
35
30
|
export interface DatasetBuilder {
|
|
36
31
|
/**
|
|
37
32
|
* `dataset` built for `caseName` (`null` builds with the parameter defaults). Throws what stops the build: a
|
|
38
|
-
* blocking problem of a definition,
|
|
33
|
+
* blocking problem of a definition, an id another dataset of this session already has, a missing parameter value, a
|
|
34
|
+
* reference cycle, an error of a `build`. A failure
|
|
39
35
|
* inside a referenced dataset is rethrown at the `ctx.ref` call, its error as the `cause`.
|
|
40
36
|
*/
|
|
41
37
|
build(dataset: Dataset, caseName: string | null): BuiltDataset;
|
package/dist/build.js
CHANGED
|
@@ -7,6 +7,10 @@ import { problemsOf } from './problems.js';
|
|
|
7
7
|
import { raw } from './query.js';
|
|
8
8
|
import { QueryExpression, from } from './sql.js';
|
|
9
9
|
import { path } from './t.js';
|
|
10
|
+
/** The dotted form of a dataset's id: one string per dataset, since no segment contains a dot. */
|
|
11
|
+
function idOf(dataset) {
|
|
12
|
+
return Dataset.definitionOf(dataset).id.join('.');
|
|
13
|
+
}
|
|
10
14
|
function messageOf(error) {
|
|
11
15
|
return error instanceof Error ? error.message : String(error);
|
|
12
16
|
}
|
|
@@ -32,6 +36,7 @@ function mergeParameters(into, parameters, origin) {
|
|
|
32
36
|
}
|
|
33
37
|
export function datasetBuilder(options) {
|
|
34
38
|
const results = new Map();
|
|
39
|
+
const datasetsById = new Map();
|
|
35
40
|
const active = [];
|
|
36
41
|
function cached(dataset, caseName) {
|
|
37
42
|
return results.get(dataset)?.get(Dataset.definitionOf(dataset).kind === 'table' ? null : caseName);
|
|
@@ -39,7 +44,7 @@ export function datasetBuilder(options) {
|
|
|
39
44
|
/** `loop` runs from a reference's target to a dataset still being built. */
|
|
40
45
|
function cycleError(loop) {
|
|
41
46
|
const start = active.indexOf(loop[loop.length - 1]);
|
|
42
|
-
return new Error(`reference cycle: ${[...active.slice(start), ...loop].map(
|
|
47
|
+
return new Error(`reference cycle: ${[...active.slice(start), ...loop].map(idOf).join(' -> ')}`);
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
45
50
|
* The path from a result built earlier down to a dataset being built now, if there is one. A build that caught a
|
|
@@ -73,7 +78,7 @@ export function datasetBuilder(options) {
|
|
|
73
78
|
ref(target, refOptions) {
|
|
74
79
|
if (!(target instanceof Dataset))
|
|
75
80
|
throw new Error('ctx.ref needs a dataset');
|
|
76
|
-
const id =
|
|
81
|
+
const id = idOf(target);
|
|
77
82
|
if (active.includes(target))
|
|
78
83
|
throw cycleError([target]);
|
|
79
84
|
const targetCase = refOptions?.case ?? null;
|
|
@@ -129,6 +134,11 @@ export function datasetBuilder(options) {
|
|
|
129
134
|
const blocking = problemsOf(definition).find((problem) => problem.blocks);
|
|
130
135
|
if (blocking)
|
|
131
136
|
throw new Error(blocking.message);
|
|
137
|
+
const id = idOf(dataset);
|
|
138
|
+
const sameId = datasetsById.get(id);
|
|
139
|
+
if (sameId && sameId !== dataset)
|
|
140
|
+
throw new Error(`two datasets have the id "${id}"`);
|
|
141
|
+
datasetsById.set(id, dataset);
|
|
132
142
|
let result;
|
|
133
143
|
active.push(dataset);
|
|
134
144
|
try {
|
package/dist/descriptor.d.ts
CHANGED
|
@@ -9,18 +9,20 @@ export interface ParamSpec {
|
|
|
9
9
|
}
|
|
10
10
|
export type ParamsSpec = Record<string, ParamSpec>;
|
|
11
11
|
interface CommonDefinition {
|
|
12
|
+
/**
|
|
13
|
+
* Names the dataset wherever it is declared, so moving the declaration does not rename it. Segments run from the
|
|
14
|
+
* outermost namespace to the name; none contains a dot, so the dotted form in messages names one dataset.
|
|
15
|
+
*/
|
|
16
|
+
id: readonly [string, ...string[]];
|
|
12
17
|
description: string;
|
|
13
|
-
grain: string;
|
|
14
18
|
/** Notes on what columns mean. Free text, never a schema. */
|
|
15
19
|
columns?: Record<string, string>;
|
|
16
|
-
/** Notes on how this dataset joins others, keyed by the other dataset's name. */
|
|
17
|
-
relations?: Record<string, string>;
|
|
18
20
|
}
|
|
19
21
|
export interface TableDefinition extends CommonDefinition {
|
|
20
22
|
kind: 'table';
|
|
21
23
|
/** Physical table name, for the builder's `tableName` to use. */
|
|
22
24
|
table?: string;
|
|
23
|
-
partitionedBy?: string;
|
|
25
|
+
partitionedBy?: readonly string[];
|
|
24
26
|
clusteredBy?: readonly string[];
|
|
25
27
|
}
|
|
26
28
|
export interface BuildContext<Name extends string = string> {
|
package/dist/problems.js
CHANGED
|
@@ -46,12 +46,13 @@ export function problemsOf(def) {
|
|
|
46
46
|
}
|
|
47
47
|
const out = [];
|
|
48
48
|
const flag = (message, blocks = false) => out.push({ message, blocks });
|
|
49
|
+
const id = def.id;
|
|
50
|
+
if (!(Array.isArray(id) && id.length > 0 && id.every((segment) => nonEmpty(segment) && !segment.includes('.')))) {
|
|
51
|
+
flag('`id` is a non-empty list of names without dots', true);
|
|
52
|
+
}
|
|
49
53
|
if (!nonEmpty(def.description))
|
|
50
54
|
flag('`description` is missing or empty');
|
|
51
|
-
if (!nonEmpty(def.grain))
|
|
52
|
-
flag('`grain` is missing or empty');
|
|
53
55
|
notes('columns', def.columns, out);
|
|
54
|
-
notes('relations', def.relations, out);
|
|
55
56
|
if (def.kind === 'table') {
|
|
56
57
|
for (const field of ['build', 'params', 'cases', 'role']) {
|
|
57
58
|
if (def[field] !== undefined)
|
|
@@ -60,11 +61,11 @@ export function problemsOf(def) {
|
|
|
60
61
|
if (def.table !== undefined && !(typeof def.table === 'string' && TABLE_NAME.test(def.table))) {
|
|
61
62
|
flag('`table` is not a valid table name', true);
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
for (const field of ['partitionedBy', 'clusteredBy']) {
|
|
65
|
+
const columns = def[field];
|
|
66
|
+
if (columns !== undefined && !(Array.isArray(columns) && columns.every(nonEmpty))) {
|
|
67
|
+
flag(`\`${field}\` is a list of column names`);
|
|
68
|
+
}
|
|
68
69
|
}
|
|
69
70
|
return out;
|
|
70
71
|
}
|