@leuduan/block 2.0.0 → 3.1.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 +17 -20
- package/dist/build.d.ts +2 -6
- package/dist/build.js +12 -2
- package/dist/descriptor.d.ts +6 -4
- package/dist/index.d.ts +1 -1
- package/dist/problems.js +9 -8
- package/dist/sql.d.ts +6 -3
- package/dist/sql.js +18 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,22 +12,28 @@ renderer (`l`, 120 columns by default) and the builders on top of it: `p` (pipes
|
|
|
12
12
|
helpers), `q` (queries), `t` (table expressions) and the `sql` tag, with `from` exported at the root. Every function
|
|
13
13
|
has one name and one home; the namespaces export factories, not the concrete classes behind them. A pipe is applied
|
|
14
14
|
one way: `pipe(query, p.where(...), ...)`. Pipe syntax is GoogleSQL's, so the result is a `GoogleSqlQuery`; applying
|
|
15
|
-
pipes to one extends its own pipe list instead of nesting
|
|
15
|
+
pipes to one extends its own pipe list instead of nesting. A step can also be a function from the query built so far
|
|
16
|
+
to the next query, for what `|>` cannot express (the input on the right of a join, inside a subquery, or rewritten);
|
|
17
|
+
later steps apply to what it returns. Reusable steps are a `Pipe[]` to spread into the call, or such a function, with
|
|
18
|
+
any inputs taken as arguments of the function that makes them.
|
|
16
19
|
Deliberate nesting is `q.googleQuery({ queryExpression, pipes })`. Pipes that take a list take an array. String
|
|
17
20
|
literals (`e.literalString`, the typed date and time literals) and quoted identifiers (`e.ident`, `t.path(id, true)`)
|
|
18
21
|
are escaped with GoogleSQL's backslash escapes.
|
|
19
22
|
|
|
20
23
|
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
|
-
|
|
24
|
+
records its call site, `problemsOf` judges a definition, and `datasetBuilder` builds them. Every definition carries
|
|
25
|
+
its own `id`, a path of names from the outermost namespace to the dataset (`['analytics', 'events']`), so a dataset
|
|
26
|
+
keeps its name wherever its declaration lives; messages show it dotted, and no segment may contain a dot. A builder is
|
|
27
|
+
one session that builds each
|
|
28
|
+
(dataset, case) once: it refuses two datasets with one id, resolves parameter values from defaults and cases, keeps
|
|
29
|
+
one value per parameter name across inlined queries, inlines references and refuses cycles, and returns the result graph
|
|
24
30
|
(`BuiltDataset`: the query, its parameters, the results it referenced, the assets it loaded). A failure inside a
|
|
25
31
|
referenced dataset is rethrown at the `ctx.ref` call with the original as its `cause`.
|
|
26
32
|
|
|
27
33
|
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
|
-
|
|
34
|
+
no defaults: `tableName` gives a table's fully-qualified name, `asset` loads an asset's SQL text. Policies over the
|
|
35
|
+
built SQL, such as a rule against handwritten table ids or references outside a catalog (the result graph lists
|
|
36
|
+
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
37
|
`e.ident(name)` for identifiers. `create_table` and the ANSI dialect (`dbox.block.ansi`) are not ported.
|
|
32
38
|
|
|
33
39
|
## Use
|
|
@@ -49,28 +55,19 @@ duplicates.format() // FROM `my-project.analytics.events` |> SELECT ...
|
|
|
49
55
|
Datasets, built standalone:
|
|
50
56
|
|
|
51
57
|
```ts
|
|
52
|
-
import { datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
|
|
58
|
+
import { Dataset, datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
|
|
53
59
|
|
|
54
|
-
const EVENTS = define({ kind: 'table',
|
|
60
|
+
const EVENTS = define({ kind: 'table', id: ['analytics', 'events'], description: 'Raw events.' })
|
|
55
61
|
const recent = define({
|
|
56
62
|
kind: 'query',
|
|
63
|
+
id: ['analytics', 'recent_events'],
|
|
57
64
|
description: 'Events since a day.',
|
|
58
|
-
grain: 'one row per event',
|
|
59
65
|
params: { day: { type: 'DATE', default: '2026-01-01' } },
|
|
60
66
|
build: (ctx) => pipe(ctx.ref(EVENTS), p.where(sql`day >= ${ctx.param('day')}`)),
|
|
61
67
|
})
|
|
62
68
|
|
|
63
|
-
const names = new Map([
|
|
64
|
-
[EVENTS, 'events'],
|
|
65
|
-
[recent, 'recent'],
|
|
66
|
-
])
|
|
67
69
|
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',
|
|
70
|
+
tableName: (dataset) => `my-project.${Dataset.definitionOf(dataset).id.join('.')}`,
|
|
74
71
|
asset: (file) => {
|
|
75
72
|
throw new Error(`no assets here: ${file}`)
|
|
76
73
|
},
|
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/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export * as q from './query.js';
|
|
|
6
6
|
export * as t from './t.js';
|
|
7
7
|
export type { Line, LineOrStr, Options } from './line.js';
|
|
8
8
|
export { DEFAULT_OPTIONS } from './line.js';
|
|
9
|
-
export type { SqlSegment, Pipe, TableExpression, GoogleSqlQuery } from './sql.js';
|
|
9
|
+
export type { SqlSegment, Pipe, PipeStep, TableExpression, GoogleSqlQuery } from './sql.js';
|
|
10
10
|
export { QueryExpression, from, pipe, sql } from './sql.js';
|
|
11
11
|
export { define } from './define.js';
|
|
12
12
|
export { Dataset, PARAM_TYPES } from './descriptor.js';
|
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
|
}
|
package/dist/sql.d.ts
CHANGED
|
@@ -53,12 +53,15 @@ export declare class GoogleSqlQuery extends QueryExpression {
|
|
|
53
53
|
constructor({ ctes, queryExpression, orderByLine, limitOffset, pipes }: GoogleSqlQueryProps);
|
|
54
54
|
toLine(): Line;
|
|
55
55
|
}
|
|
56
|
+
/** A step of `pipe`: a `|>` operator, or a function from the query built so far to the next query. */
|
|
57
|
+
export type PipeStep = Pipe | ((query: QueryExpression) => QueryExpression);
|
|
56
58
|
/**
|
|
57
|
-
* Apply
|
|
58
|
-
*
|
|
59
|
-
* changed.
|
|
59
|
+
* Apply steps to a query, left to right. `|>` operators are GoogleSQL's pipe syntax, so a call of only those returns a
|
|
60
|
+
* GoogleSqlQuery; a function step gets the query built so far and its result is what later steps apply to. The input
|
|
61
|
+
* is not changed.
|
|
60
62
|
*/
|
|
61
63
|
export declare function pipe(query: QueryExpression, ...steps: Pipe[]): GoogleSqlQuery;
|
|
64
|
+
export declare function pipe(query: QueryExpression, ...steps: PipeStep[]): QueryExpression;
|
|
62
65
|
type SqlInterp = string | number | Line | SqlSegment;
|
|
63
66
|
/**
|
|
64
67
|
* Tagged template for raw SQL fragments.
|
package/dist/sql.js
CHANGED
|
@@ -119,22 +119,32 @@ export class GoogleSqlQuery extends QueryExpression {
|
|
|
119
119
|
]);
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
* the steps on its own pipe list (only its outer wrapper is flattened); anything else is wrapped. The input is not
|
|
125
|
-
* changed.
|
|
126
|
-
*/
|
|
127
|
-
export function pipe(query, ...steps) {
|
|
122
|
+
/** A GoogleSqlQuery takes the pipes on its own pipe list (only its outer wrapper is flattened); anything else is wrapped. */
|
|
123
|
+
function appendPipes(query, pipes) {
|
|
128
124
|
if (query instanceof GoogleSqlQuery) {
|
|
129
125
|
return new GoogleSqlQuery({
|
|
130
126
|
ctes: query.ctes,
|
|
131
127
|
queryExpression: query.queryExpression,
|
|
132
128
|
orderByLine: query.orderByLine,
|
|
133
129
|
limitOffset: query.limitOffset,
|
|
134
|
-
pipes: [...query.pipes, ...
|
|
130
|
+
pipes: [...query.pipes, ...pipes],
|
|
135
131
|
});
|
|
136
132
|
}
|
|
137
|
-
return new GoogleSqlQuery({ queryExpression: query, pipes
|
|
133
|
+
return new GoogleSqlQuery({ queryExpression: query, pipes });
|
|
134
|
+
}
|
|
135
|
+
export function pipe(query, ...steps) {
|
|
136
|
+
let result = query;
|
|
137
|
+
let pending = [];
|
|
138
|
+
for (const step of steps) {
|
|
139
|
+
if (step instanceof Pipe) {
|
|
140
|
+
pending.push(step);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
result = step(pending.length > 0 ? appendPipes(result, pending) : result);
|
|
144
|
+
pending = [];
|
|
145
|
+
}
|
|
146
|
+
const endsWithFunction = steps.length > 0 && !(steps[steps.length - 1] instanceof Pipe);
|
|
147
|
+
return endsWithFunction ? result : appendPipes(result, pending);
|
|
138
148
|
}
|
|
139
149
|
/**
|
|
140
150
|
* Tagged template for raw SQL fragments.
|