yamchart 0.4.11 → 0.4.13
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/dist/{dev-WS4UALYA.js → dev-PV6RXXCP.js} +15 -15
- package/dist/index.js +3 -3
- package/dist/templates/default/CLAUDE.md +24 -2
- package/dist/templates/default/docs/yamchart-reference.md +313 -37
- package/dist/{test-IL3UPLUJ.js → test-3PFV2KAP.js} +10 -38
- package/dist/test-3PFV2KAP.js.map +1 -0
- package/package.json +3 -3
- package/dist/test-IL3UPLUJ.js.map +0 -1
- /package/dist/{dev-WS4UALYA.js.map → dev-PV6RXXCP.js.map} +0 -0
|
@@ -2,20 +2,6 @@ import {
|
|
|
2
2
|
loadEnvFile,
|
|
3
3
|
validateProject
|
|
4
4
|
} from "./chunk-6XQITSPY.js";
|
|
5
|
-
import {
|
|
6
|
-
ChartSchema,
|
|
7
|
-
ConnectionSchema,
|
|
8
|
-
DashboardSchema,
|
|
9
|
-
ProjectSchema,
|
|
10
|
-
ScheduleSchema
|
|
11
|
-
} from "./chunk-R7EULFJG.js";
|
|
12
|
-
import {
|
|
13
|
-
AuthDatabase,
|
|
14
|
-
generateSessionToken,
|
|
15
|
-
hashPassword,
|
|
16
|
-
parseTtl,
|
|
17
|
-
verifyPassword
|
|
18
|
-
} from "./chunk-4P5UHWYK.js";
|
|
19
5
|
import {
|
|
20
6
|
box,
|
|
21
7
|
detail,
|
|
@@ -26,6 +12,20 @@ import {
|
|
|
26
12
|
spinner,
|
|
27
13
|
success
|
|
28
14
|
} from "./chunk-HJVVHYVN.js";
|
|
15
|
+
import {
|
|
16
|
+
AuthDatabase,
|
|
17
|
+
generateSessionToken,
|
|
18
|
+
hashPassword,
|
|
19
|
+
parseTtl,
|
|
20
|
+
verifyPassword
|
|
21
|
+
} from "./chunk-4P5UHWYK.js";
|
|
22
|
+
import {
|
|
23
|
+
ChartSchema,
|
|
24
|
+
ConnectionSchema,
|
|
25
|
+
DashboardSchema,
|
|
26
|
+
ProjectSchema,
|
|
27
|
+
ScheduleSchema
|
|
28
|
+
} from "./chunk-R7EULFJG.js";
|
|
29
29
|
import {
|
|
30
30
|
DuckDBConnector,
|
|
31
31
|
MySQLConnector,
|
|
@@ -13790,4 +13790,4 @@ async function runDevServer(projectDir, options) {
|
|
|
13790
13790
|
export {
|
|
13791
13791
|
runDevServer
|
|
13792
13792
|
};
|
|
13793
|
-
//# sourceMappingURL=dev-
|
|
13793
|
+
//# sourceMappingURL=dev-PV6RXXCP.js.map
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
loadEnvFile,
|
|
8
8
|
validateProject
|
|
9
9
|
} from "./chunk-6XQITSPY.js";
|
|
10
|
-
import "./chunk-R7EULFJG.js";
|
|
11
10
|
import {
|
|
12
11
|
detail,
|
|
13
12
|
error,
|
|
@@ -18,6 +17,7 @@ import {
|
|
|
18
17
|
success,
|
|
19
18
|
warning
|
|
20
19
|
} from "./chunk-HJVVHYVN.js";
|
|
20
|
+
import "./chunk-R7EULFJG.js";
|
|
21
21
|
import "./chunk-23E6YT4S.js";
|
|
22
22
|
import "./chunk-DGUM43GV.js";
|
|
23
23
|
|
|
@@ -96,7 +96,7 @@ program.command("dev").description("Start development server with hot reload").a
|
|
|
96
96
|
detail("Run this command from a yamchart project directory");
|
|
97
97
|
process.exit(2);
|
|
98
98
|
}
|
|
99
|
-
const { runDevServer } = await import("./dev-
|
|
99
|
+
const { runDevServer } = await import("./dev-PV6RXXCP.js");
|
|
100
100
|
await runDevServer(projectDir, {
|
|
101
101
|
port: parseInt(options.port, 10),
|
|
102
102
|
apiOnly: options.apiOnly ?? false,
|
|
@@ -195,7 +195,7 @@ program.command("test").description("Run model tests (@returns schema checks and
|
|
|
195
195
|
}
|
|
196
196
|
loadEnvFile(projectDir);
|
|
197
197
|
try {
|
|
198
|
-
const { testProject, formatTestOutput } = await import("./test-
|
|
198
|
+
const { testProject, formatTestOutput } = await import("./test-3PFV2KAP.js");
|
|
199
199
|
const result = await testProject(projectDir, model, {
|
|
200
200
|
connection: options.connection,
|
|
201
201
|
json: options.json
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
# {{name}}
|
|
2
2
|
|
|
3
|
+
This is a [Yamchart](https://github.com/simon-spenc/yamchart) project — Git-native BI dashboards defined as YAML configs and SQL models.
|
|
4
|
+
|
|
3
5
|
## Project Notes
|
|
4
6
|
|
|
5
|
-
Add your project-specific notes, conventions, and context here.
|
|
7
|
+
Add your project-specific notes, conventions, and context here (e.g. database schema, naming conventions, team preferences).
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
yamchart dev # Start dev server with hot reload
|
|
13
|
+
yamchart validate # Validate all config files
|
|
14
|
+
yamchart test # Run model assertions
|
|
15
|
+
yamchart tables # List database tables
|
|
16
|
+
yamchart describe <table> # Show table columns
|
|
17
|
+
yamchart search <keyword> # Find tables/columns
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## File Structure
|
|
21
|
+
|
|
22
|
+
- `yamchart.yaml` — project config, default connection, theme, auth
|
|
23
|
+
- `connections/*.yaml` — database connections (DuckDB, Postgres, MySQL, SQLite, Snowflake)
|
|
24
|
+
- `models/*.sql` — SQL with Jinja templating (`{{ param }}`, `{% if %}`, `{{ ref('table') }}`)
|
|
25
|
+
- `charts/*.yaml` — chart definitions (line, bar, area, pie, donut, scatter, kpi, combo, heatmap, funnel, waterfall, gauge, table)
|
|
26
|
+
- `dashboards/*.yaml` — 12-column grid layouts with chart/text widgets, optional tabs
|
|
27
|
+
- `schedules/*.yaml` — cron-based Slack reports and threshold alerts
|
|
6
28
|
|
|
7
29
|
## Yamchart Reference
|
|
8
30
|
|
|
9
|
-
See [docs/yamchart-reference.md](docs/yamchart-reference.md) for the full
|
|
31
|
+
See [docs/yamchart-reference.md](docs/yamchart-reference.md) for the full configuration reference including all chart types, parameters, dashboard tabs, filters, connections, schedules, authentication, and formatting options.
|
|
@@ -6,11 +6,12 @@ Yamchart is a Git-native BI framework. Dashboards are defined as YAML configs an
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
{{name}}/
|
|
9
|
-
├── yamchart.yaml # Project config
|
|
9
|
+
├── yamchart.yaml # Project config, default connection, theme, auth
|
|
10
10
|
├── connections/ # Database connection configs (.yaml)
|
|
11
11
|
├── models/ # SQL models with Jinja templating (.sql)
|
|
12
12
|
├── charts/ # Chart definitions (.yaml)
|
|
13
|
-
|
|
13
|
+
├── dashboards/ # Dashboard layouts (.yaml)
|
|
14
|
+
└── schedules/ # Scheduled reports and alerts (.yaml)
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
## Commands
|
|
@@ -21,11 +22,16 @@ yamchart validate # Validate all config files
|
|
|
21
22
|
yamchart validate --dry-run # Also test queries with EXPLAIN
|
|
22
23
|
yamchart test # Run model @returns and @tests assertions
|
|
23
24
|
yamchart test <model> # Test specific model
|
|
25
|
+
yamchart update # Check for and install updates
|
|
24
26
|
yamchart tables # List tables in connected database
|
|
27
|
+
yamchart tables --schema public # Filter to a specific schema
|
|
25
28
|
yamchart describe <table> # Show columns and types (accepts dbt model names)
|
|
26
29
|
yamchart sample <table> # Show sample rows (default: 5, accepts dbt model names)
|
|
30
|
+
yamchart sample <table> -l 10 # Custom row limit
|
|
27
31
|
yamchart search <keyword> # Find tables and columns matching keyword
|
|
28
|
-
yamchart query "SELECT ..." # Execute ad-hoc SQL
|
|
32
|
+
yamchart query "SELECT ..." # Execute ad-hoc SQL (default limit: 100)
|
|
33
|
+
yamchart query "SELECT ..." --limit 500 # Custom row limit
|
|
34
|
+
yamchart reset-password --email <e> # Reset a user's password (requires auth enabled)
|
|
29
35
|
```
|
|
30
36
|
|
|
31
37
|
## SQL Models (`models/*.sql`)
|
|
@@ -35,9 +41,13 @@ Models are SQL files with metadata comments and Jinja templating.
|
|
|
35
41
|
```sql
|
|
36
42
|
-- @name: model_name
|
|
37
43
|
-- @description: What this model computes
|
|
44
|
+
-- @param start_date: date = 2025-01-01
|
|
45
|
+
-- @param category: string = All
|
|
38
46
|
-- @returns:
|
|
39
|
-
-- -
|
|
40
|
-
-- -
|
|
47
|
+
-- - period: date
|
|
48
|
+
-- - revenue: number
|
|
49
|
+
-- @tests:
|
|
50
|
+
-- - SELECT * FROM ({{this}}) t WHERE revenue < 0
|
|
41
51
|
|
|
42
52
|
SELECT
|
|
43
53
|
date_trunc('{{ granularity }}', order_date) AS period,
|
|
@@ -47,16 +57,21 @@ WHERE order_date BETWEEN '{{ start_date }}' AND '{{ end_date }}'
|
|
|
47
57
|
{% if category and category != 'All' %}
|
|
48
58
|
AND category = '{{ category }}'
|
|
49
59
|
{% endif %}
|
|
60
|
+
{% if user.department %}
|
|
61
|
+
AND department = '{{ user.department }}'
|
|
62
|
+
{% endif %}
|
|
50
63
|
GROUP BY 1
|
|
51
64
|
ORDER BY 1
|
|
52
65
|
```
|
|
53
66
|
|
|
54
67
|
**Metadata tags:** `@name` (required), `@description`, `@param name: type = default`, `@returns` (column: type list), `@tests` (SQL assertions where zero rows = pass)
|
|
55
68
|
|
|
56
|
-
**Templating:** `{{ variable }}` for substitution, `{% if %}` / `{% endif %}` for conditionals, `{{ ref('table') }}` for table references, `{{ user.attribute }}` for row-level security.
|
|
69
|
+
**Templating:** `{{ variable }}` for substitution, `{% if %}` / `{% endif %}` for conditionals, `{{ ref('table') }}` for table references, `{{ user.email|role|<attribute> }}` for row-level security.
|
|
57
70
|
|
|
58
71
|
**Date parameters:** When a chart has `type: date_range`, the model receives `start_date` and `end_date` automatically.
|
|
59
72
|
|
|
73
|
+
**Tests:** `{{this}}` in `@tests` expands to the compiled SQL. Zero rows returned = pass; any rows = failure.
|
|
74
|
+
|
|
60
75
|
## Charts (`charts/*.yaml`)
|
|
61
76
|
|
|
62
77
|
### Common Structure
|
|
@@ -67,10 +82,16 @@ title: My Chart # Display title (required)
|
|
|
67
82
|
description: Description # Optional
|
|
68
83
|
source:
|
|
69
84
|
model: model_name # SQL model to use
|
|
85
|
+
connection: prod # Optional — override default connection
|
|
70
86
|
parameters: [] # Interactive filters (optional)
|
|
71
87
|
chart:
|
|
72
88
|
type: line # Chart type
|
|
73
89
|
# ... type-specific config
|
|
90
|
+
drillDown: # Optional — right-click to navigate
|
|
91
|
+
chart: detail-chart
|
|
92
|
+
field: category
|
|
93
|
+
refresh: # Optional — caching/scheduling
|
|
94
|
+
cache_ttl: "30m"
|
|
74
95
|
```
|
|
75
96
|
|
|
76
97
|
### Chart Types
|
|
@@ -83,6 +104,8 @@ chart:
|
|
|
83
104
|
y: { field: revenue, format: "$,.0f" }
|
|
84
105
|
series:
|
|
85
106
|
field: category # Multi-series by grouping field (optional)
|
|
107
|
+
stacking: stacked # Optional: stacked or percent (bar/area only)
|
|
108
|
+
gradient: true # Optional: auto gradient on fill
|
|
86
109
|
```
|
|
87
110
|
|
|
88
111
|
**Pie / Donut** — part-to-whole:
|
|
@@ -91,6 +114,10 @@ chart:
|
|
|
91
114
|
type: pie # or donut
|
|
92
115
|
x: { field: segment, type: nominal }
|
|
93
116
|
y: { field: amount, format: "$,.0f" }
|
|
117
|
+
centerValue: # Donut only — center display
|
|
118
|
+
field: total # 'total' sums all values, or use a field name
|
|
119
|
+
label: "Total"
|
|
120
|
+
format: "$,.0f"
|
|
94
121
|
```
|
|
95
122
|
|
|
96
123
|
**Scatter** — correlations with optional size/group/regression:
|
|
@@ -99,28 +126,47 @@ chart:
|
|
|
99
126
|
type: scatter
|
|
100
127
|
x: { field: revenue, type: quantitative }
|
|
101
128
|
y: { field: orders, type: quantitative }
|
|
102
|
-
size: { field: avg_value, min: 8, max: 40 }
|
|
103
|
-
group: { field: region }
|
|
104
|
-
regression:
|
|
129
|
+
size: { field: avg_value, min: 8, max: 40, label: "Avg Value" } # Bubble encoding
|
|
130
|
+
group: { field: region } # Color by category
|
|
131
|
+
regression: # Trend line
|
|
132
|
+
type: linear
|
|
133
|
+
show_equation: true
|
|
134
|
+
show_r_squared: true
|
|
105
135
|
```
|
|
106
136
|
|
|
107
|
-
**KPI** — single metric with comparison:
|
|
137
|
+
**KPI** — single metric with optional comparison:
|
|
108
138
|
```yaml
|
|
109
139
|
chart:
|
|
110
140
|
type: kpi
|
|
111
|
-
value: { field:
|
|
141
|
+
value: { field: revenue }
|
|
112
142
|
format: { type: currency, currency: USD, decimals: 0 }
|
|
113
|
-
|
|
143
|
+
unit: "sessions" # Optional suffix label
|
|
144
|
+
|
|
145
|
+
# Option A: Auto comparison (server computes previous period)
|
|
146
|
+
comparison:
|
|
147
|
+
enabled: true
|
|
148
|
+
period: previous # Server shifts date range back automatically
|
|
149
|
+
type: percent_change # or absolute
|
|
150
|
+
|
|
151
|
+
# Option B: Manual comparison (use a column from query results)
|
|
152
|
+
comparison:
|
|
153
|
+
enabled: true
|
|
154
|
+
field: previous_value # Column name with the comparison value
|
|
155
|
+
label: "vs last month"
|
|
156
|
+
type: percent_change
|
|
114
157
|
```
|
|
115
158
|
|
|
159
|
+
> **Auto comparison:** `period: previous` runs the model a second time with the shifted date range. It infers the shift from the active date preset (e.g. `last_30_days` → previous 30 days). Displays period labels, change badge, and "vs" previous value automatically.
|
|
160
|
+
|
|
116
161
|
**Heatmap** — two-dimensional intensity:
|
|
117
162
|
```yaml
|
|
118
163
|
chart:
|
|
119
164
|
type: heatmap
|
|
120
165
|
x: { field: hour, type: ordinal }
|
|
121
166
|
y: { field: day, type: ordinal }
|
|
122
|
-
value: { field: count }
|
|
167
|
+
value: { field: count, label: "Count" }
|
|
123
168
|
color_range: { min: '#EBF5FB', max: '#1A5276' }
|
|
169
|
+
show_values: true
|
|
124
170
|
```
|
|
125
171
|
|
|
126
172
|
**Funnel** — conversion stages:
|
|
@@ -129,6 +175,7 @@ chart:
|
|
|
129
175
|
type: funnel
|
|
130
176
|
stage: { field: stage_name }
|
|
131
177
|
value: { field: user_count }
|
|
178
|
+
show_conversion: true
|
|
132
179
|
```
|
|
133
180
|
|
|
134
181
|
**Waterfall** — incremental deltas:
|
|
@@ -137,7 +184,11 @@ chart:
|
|
|
137
184
|
type: waterfall
|
|
138
185
|
category: { field: label }
|
|
139
186
|
value: { field: amount }
|
|
140
|
-
total_field: is_total
|
|
187
|
+
total_field: is_total # Column name marking total rows
|
|
188
|
+
colors:
|
|
189
|
+
increase: "#10B981"
|
|
190
|
+
decrease: "#EF4444"
|
|
191
|
+
total: "#3B82F6"
|
|
141
192
|
```
|
|
142
193
|
|
|
143
194
|
**Gauge** — single metric dial:
|
|
@@ -151,6 +202,7 @@ chart:
|
|
|
151
202
|
- { value: 60, color: '#EF4444' }
|
|
152
203
|
- { value: 85, color: '#F59E0B' }
|
|
153
204
|
- { value: 100, color: '#22C55E' }
|
|
205
|
+
format: ".1f"
|
|
154
206
|
```
|
|
155
207
|
|
|
156
208
|
**Table** — sortable data table (renders all query columns by default):
|
|
@@ -178,7 +230,7 @@ chart:
|
|
|
178
230
|
|
|
179
231
|
### Multi-Series
|
|
180
232
|
|
|
181
|
-
**Long format** — group by a field:
|
|
233
|
+
**Long format** — group by a field in the data:
|
|
182
234
|
```yaml
|
|
183
235
|
series:
|
|
184
236
|
field: region
|
|
@@ -191,19 +243,27 @@ series:
|
|
|
191
243
|
```yaml
|
|
192
244
|
series:
|
|
193
245
|
columns:
|
|
194
|
-
-
|
|
195
|
-
|
|
246
|
+
- field: revenue
|
|
247
|
+
name: Revenue
|
|
248
|
+
color: "#3B82F6"
|
|
249
|
+
- field: cost
|
|
250
|
+
name: Cost
|
|
251
|
+
color: "#EF4444"
|
|
252
|
+
style: dashed # solid, dashed, or dotted
|
|
253
|
+
opacity: 0.8 # 0–1
|
|
254
|
+
gradient: true # or { from: "#hex", to: "#hex" }
|
|
196
255
|
```
|
|
197
256
|
|
|
198
257
|
### Stacking
|
|
199
258
|
|
|
200
259
|
```yaml
|
|
201
260
|
chart:
|
|
202
|
-
type: bar
|
|
203
|
-
stacking: stacked #
|
|
261
|
+
type: bar # or area
|
|
262
|
+
stacking: stacked # normal stacking
|
|
263
|
+
stacking: percent # normalizes to 100%, caps y-axis, shows % labels
|
|
204
264
|
```
|
|
205
265
|
|
|
206
|
-
### Parameters
|
|
266
|
+
### Parameters (Interactive Filters)
|
|
207
267
|
|
|
208
268
|
```yaml
|
|
209
269
|
parameters:
|
|
@@ -213,23 +273,47 @@ parameters:
|
|
|
213
273
|
|
|
214
274
|
- name: region
|
|
215
275
|
type: select
|
|
276
|
+
label: Region
|
|
216
277
|
options: [North, South, East, West]
|
|
217
278
|
default: North
|
|
218
279
|
|
|
280
|
+
- name: tags
|
|
281
|
+
type: multi_select
|
|
282
|
+
options:
|
|
283
|
+
- value: a
|
|
284
|
+
label: Category A
|
|
285
|
+
- value: b
|
|
286
|
+
label: Category B
|
|
287
|
+
|
|
219
288
|
- name: category
|
|
220
289
|
type: dynamic_select
|
|
221
290
|
source: { model: category_options, value_field: id, label_field: name }
|
|
291
|
+
|
|
292
|
+
- name: search_term
|
|
293
|
+
type: text
|
|
294
|
+
|
|
295
|
+
- name: min_revenue
|
|
296
|
+
type: number
|
|
297
|
+
default: 0
|
|
298
|
+
|
|
299
|
+
- name: is_active
|
|
300
|
+
type: boolean
|
|
301
|
+
default: true
|
|
222
302
|
```
|
|
223
303
|
|
|
224
|
-
**
|
|
304
|
+
**Parameter types:** `date_range`, `select`, `multi_select`, `dynamic_select`, `text`, `number`, `boolean`
|
|
305
|
+
|
|
306
|
+
**Date presets:** `last_7_days`, `last_30_days`, `last_90_days`, `last_12_months`, `year_to_date`, `month_to_date`, `quarter_to_date`, `previous_month`, `previous_quarter`, `previous_year`
|
|
225
307
|
|
|
226
308
|
### Drill-Down
|
|
227
309
|
|
|
310
|
+
Right-click any chart element to navigate to a related detail chart with filtered data.
|
|
311
|
+
|
|
228
312
|
```yaml
|
|
229
313
|
drillDown:
|
|
230
314
|
chart: detail-chart # Target chart name
|
|
231
|
-
field: category # Field to pass as filter
|
|
232
|
-
columns: # Optional table columns on landing
|
|
315
|
+
field: category # Field to pass as filter (defaults to x-axis field)
|
|
316
|
+
columns: # Optional table columns on landing page
|
|
233
317
|
- { field: name, label: Name }
|
|
234
318
|
- { field: revenue, label: Revenue, format: "$,.0f" }
|
|
235
319
|
```
|
|
@@ -239,27 +323,53 @@ drillDown:
|
|
|
239
323
|
```yaml
|
|
240
324
|
chart:
|
|
241
325
|
gradient: true # Auto gradient
|
|
326
|
+
gradient: { from: "#3B82F6", to: "#1E40AF" } # Custom gradient
|
|
327
|
+
|
|
242
328
|
color: "#3B82F6" # Single color override
|
|
243
|
-
|
|
329
|
+
|
|
330
|
+
color: # Conditional coloring (bar charts)
|
|
244
331
|
conditions:
|
|
245
332
|
- { when: "> 0", color: "#10B981" }
|
|
246
333
|
- { when: "< 0", color: "#EF4444" }
|
|
247
334
|
default: "#6B7280"
|
|
248
335
|
```
|
|
249
336
|
|
|
337
|
+
### Axis Schema
|
|
338
|
+
|
|
339
|
+
```yaml
|
|
340
|
+
x:
|
|
341
|
+
field: date
|
|
342
|
+
type: temporal # temporal, quantitative, ordinal, or nominal
|
|
343
|
+
format: "$,.0f" # Optional d3-format / strftime string
|
|
344
|
+
label: "Date" # Optional axis label
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Caching
|
|
348
|
+
|
|
349
|
+
```yaml
|
|
350
|
+
refresh:
|
|
351
|
+
cache_ttl: "30m" # Cache duration: 30s, 5m, 1h, 1d
|
|
352
|
+
schedule: "0 * * * *" # Optional cron for pre-warming
|
|
353
|
+
timezone: America/New_York # Optional
|
|
354
|
+
```
|
|
355
|
+
|
|
250
356
|
## Dashboards (`dashboards/*.yaml`)
|
|
251
357
|
|
|
252
358
|
12-column grid layout with chart and text widgets.
|
|
253
359
|
|
|
360
|
+
### Basic Layout
|
|
361
|
+
|
|
254
362
|
```yaml
|
|
255
363
|
name: overview
|
|
256
364
|
title: Executive Overview
|
|
365
|
+
description: High-level business metrics
|
|
257
366
|
|
|
258
|
-
filters:
|
|
367
|
+
filters: # Optional — dashboard-level filters (shared across all tabs)
|
|
259
368
|
- { name: date_range, type: date_range, default: last_30_days }
|
|
369
|
+
- { name: region, type: select, options: [All, North, South], default: All }
|
|
260
370
|
|
|
261
371
|
layout:
|
|
262
|
-
gap: 16
|
|
372
|
+
gap: 16 # Pixels between widgets (default: 16)
|
|
263
373
|
rows:
|
|
264
374
|
- height: 180
|
|
265
375
|
widgets:
|
|
@@ -277,7 +387,62 @@ layout:
|
|
|
277
387
|
vs last month: {{revenue-kpi@previous_month}}
|
|
278
388
|
```
|
|
279
389
|
|
|
280
|
-
|
|
390
|
+
### Dashboard Tabs
|
|
391
|
+
|
|
392
|
+
Split a dashboard into tabbed sub-pages. Use `tabs` instead of `layout` (mutually exclusive).
|
|
393
|
+
|
|
394
|
+
```yaml
|
|
395
|
+
name: analytics
|
|
396
|
+
title: Analytics Dashboard
|
|
397
|
+
|
|
398
|
+
filters: # Dashboard-level — shown on all tabs
|
|
399
|
+
- { name: date_range, type: date_range, default: last_30_days }
|
|
400
|
+
|
|
401
|
+
tabs:
|
|
402
|
+
- name: overview
|
|
403
|
+
label: Overview
|
|
404
|
+
layout:
|
|
405
|
+
rows:
|
|
406
|
+
- height: 400
|
|
407
|
+
widgets:
|
|
408
|
+
- { type: chart, ref: revenue-trend, cols: 12 }
|
|
409
|
+
|
|
410
|
+
- name: details
|
|
411
|
+
label: Details
|
|
412
|
+
filters: # Tab-level — only shown on this tab
|
|
413
|
+
- { name: category, type: select, options: [All, A, B, C] }
|
|
414
|
+
layout:
|
|
415
|
+
rows:
|
|
416
|
+
- height: 400
|
|
417
|
+
widgets:
|
|
418
|
+
- { type: chart, ref: detail-table, cols: 12 }
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Tab navigation is reflected in the URL as `?tab=details`. Dashboard-level filters persist across tabs; tab-level filters are scoped to their tab.
|
|
422
|
+
|
|
423
|
+
### KPI References in Text Widgets
|
|
424
|
+
|
|
425
|
+
Embed live KPI values in markdown text widgets:
|
|
426
|
+
|
|
427
|
+
| Syntax | Description |
|
|
428
|
+
|--------|-------------|
|
|
429
|
+
| `{{chartName}}` | Primary value from a KPI chart |
|
|
430
|
+
| `{{chart.field}}` | Specific field from chart results |
|
|
431
|
+
| `{{chart@preset}}` | Value with a date preset applied |
|
|
432
|
+
| `{{chart@2025-01-01..2025-12-31}}` | Value with a fixed date range |
|
|
433
|
+
|
|
434
|
+
### Cross-Filtering
|
|
435
|
+
|
|
436
|
+
Click any chart element on a dashboard to filter all other charts by the clicked dimension. Cross-filters are dashboard-scoped and auto-clear on navigation.
|
|
437
|
+
|
|
438
|
+
### Edit Mode
|
|
439
|
+
|
|
440
|
+
The dashboard UI includes an edit mode for drag-and-drop layout editing:
|
|
441
|
+
- Resize and reposition widgets on the grid
|
|
442
|
+
- Add new chart or text widgets via the toolbar
|
|
443
|
+
- Double-click text widgets to edit markdown inline (KPI autocomplete via `{`)
|
|
444
|
+
- Undo/redo support
|
|
445
|
+
- Changes are saved back to the YAML file
|
|
281
446
|
|
|
282
447
|
## Connections (`connections/*.yaml`)
|
|
283
448
|
|
|
@@ -296,18 +461,137 @@ database: analytics
|
|
|
296
461
|
user: ${DB_USER}
|
|
297
462
|
password: ${DB_PASSWORD}
|
|
298
463
|
ssl: true
|
|
464
|
+
schema: public
|
|
465
|
+
|
|
466
|
+
# MySQL
|
|
467
|
+
name: mysql-db
|
|
468
|
+
type: mysql
|
|
469
|
+
config: { host: ${DB_HOST}, port: 3306, database: mydb }
|
|
470
|
+
auth: { type: env, user_var: MYSQL_USER, password_var: MYSQL_PASSWORD }
|
|
471
|
+
|
|
472
|
+
# Snowflake
|
|
473
|
+
name: snow
|
|
474
|
+
type: snowflake
|
|
475
|
+
config: { account: xy12345, warehouse: COMPUTE_WH, database: ANALYTICS, schema: PUBLIC }
|
|
476
|
+
auth: { type: env, user_var: SF_USER, password_var: SF_PASSWORD }
|
|
477
|
+
# SSO: auth: { type: externalbrowser, user_var: SF_USER }
|
|
478
|
+
|
|
479
|
+
# SQLite
|
|
480
|
+
name: local-sqlite
|
|
481
|
+
type: sqlite
|
|
482
|
+
path: ./data/app.db
|
|
299
483
|
```
|
|
300
484
|
|
|
301
485
|
**Supported:** DuckDB, PostgreSQL, MySQL, SQLite, Snowflake
|
|
302
486
|
|
|
303
487
|
Use `${ENV_VAR}` for credentials. Never commit secrets.
|
|
304
488
|
|
|
489
|
+
## Schedules (`schedules/*.yaml`)
|
|
490
|
+
|
|
491
|
+
### Scheduled Report
|
|
492
|
+
|
|
493
|
+
Deliver chart data summaries to Slack on a cron schedule.
|
|
494
|
+
|
|
495
|
+
```yaml
|
|
496
|
+
name: weekly_sales_report
|
|
497
|
+
type: report
|
|
498
|
+
schedule: "0 9 * * MON" # Cron (5-6 fields)
|
|
499
|
+
timezone: America/New_York # Optional
|
|
500
|
+
channel:
|
|
501
|
+
slack:
|
|
502
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
503
|
+
channel: "#sales" # Optional display override
|
|
504
|
+
charts:
|
|
505
|
+
- revenue_by_region
|
|
506
|
+
- top_products
|
|
507
|
+
params:
|
|
508
|
+
date_range: last_7_days
|
|
509
|
+
message: "Weekly sales report" # Optional header text
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Threshold Alert
|
|
513
|
+
|
|
514
|
+
Monitor a chart value against a threshold with cooldown.
|
|
515
|
+
|
|
516
|
+
```yaml
|
|
517
|
+
name: revenue_drop_alert
|
|
518
|
+
type: alert
|
|
519
|
+
schedule: "*/15 * * * *" # Check every 15 minutes
|
|
520
|
+
channel:
|
|
521
|
+
slack:
|
|
522
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
523
|
+
chart: daily_revenue # Single chart
|
|
524
|
+
params:
|
|
525
|
+
date_range: last_7_days
|
|
526
|
+
condition:
|
|
527
|
+
field: revenue
|
|
528
|
+
operator: lt # lt, gt, lte, gte, eq
|
|
529
|
+
value: 1000
|
|
530
|
+
cooldown: "1h" # Min time between alerts (30s, 5m, 1h, 1d)
|
|
531
|
+
message: "Revenue dropped below $1,000! Current: ${{value}}"
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
**Alert behavior:** Evaluates the first row of query results. Empty results skip evaluation. `{{value}}` in the message is replaced with the actual value. Cooldown is in-memory and resets on server restart.
|
|
535
|
+
|
|
536
|
+
## Authentication (`yamchart.yaml`)
|
|
537
|
+
|
|
538
|
+
Optional built-in authentication for self-hosted deployments.
|
|
539
|
+
|
|
540
|
+
```yaml
|
|
541
|
+
auth:
|
|
542
|
+
enabled: true
|
|
543
|
+
db_path: ~/.yamchart/auth.db # Optional (default shown)
|
|
544
|
+
session_ttl: "30d" # Optional (default: 30 days)
|
|
545
|
+
providers: # Optional SSO
|
|
546
|
+
google:
|
|
547
|
+
client_id: ${GOOGLE_CLIENT_ID}
|
|
548
|
+
client_secret: ${GOOGLE_CLIENT_SECRET}
|
|
549
|
+
microsoft:
|
|
550
|
+
client_id: ${MS_CLIENT_ID}
|
|
551
|
+
client_secret: ${MS_CLIENT_SECRET}
|
|
552
|
+
tenant: ${MS_TENANT_ID}
|
|
553
|
+
oidc:
|
|
554
|
+
client_id: ${OIDC_CLIENT_ID}
|
|
555
|
+
client_secret: ${OIDC_CLIENT_SECRET}
|
|
556
|
+
issuer: https://auth.example.com
|
|
557
|
+
display_name: "Company SSO"
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- First visit prompts a setup wizard to create an admin account
|
|
561
|
+
- **Roles:** admin (manage users + content), editor (content only), viewer (read-only)
|
|
562
|
+
- **User attributes:** Admins can set key-value pairs per user (e.g. `department: Sales`) for row-level security
|
|
563
|
+
- **RLS in SQL:** `{{ user.email }}`, `{{ user.role }}`, `{{ user.department }}` etc.
|
|
564
|
+
- **CLI recovery:** `yamchart reset-password --email user@example.com`
|
|
565
|
+
|
|
566
|
+
## Project Config (`yamchart.yaml`)
|
|
567
|
+
|
|
568
|
+
```yaml
|
|
569
|
+
version: "1"
|
|
570
|
+
name: my-project
|
|
571
|
+
description: Analytics dashboards
|
|
572
|
+
|
|
573
|
+
defaults:
|
|
574
|
+
connection: local # Default connection name
|
|
575
|
+
cache_ttl: "30m" # Default cache TTL
|
|
576
|
+
base_url: https://bi.example.com # For SSO callbacks and alert links
|
|
577
|
+
week_start: monday # sunday–saturday
|
|
578
|
+
|
|
579
|
+
theme:
|
|
580
|
+
palette: ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"]
|
|
581
|
+
gradient: false
|
|
582
|
+
opacity: 1.0
|
|
583
|
+
|
|
584
|
+
auth:
|
|
585
|
+
enabled: false # Enable built-in authentication
|
|
586
|
+
```
|
|
587
|
+
|
|
305
588
|
## Axis Types
|
|
306
589
|
|
|
307
590
|
| Type | Use | Example |
|
|
308
591
|
|------|-----|---------|
|
|
309
592
|
| `temporal` | Dates/times | `2024-01-15` |
|
|
310
|
-
| `ordinal` |
|
|
593
|
+
| `ordinal` | Ordered categories | `Mon, Tue, Wed` |
|
|
594
|
+
| `nominal` | Unordered categories | `Electronics` |
|
|
311
595
|
| `quantitative` | Numbers | `250.5` |
|
|
312
596
|
|
|
313
597
|
## Number Formats (d3-format)
|
|
@@ -318,12 +602,4 @@ Use `${ENV_VAR}` for credentials. Never commit secrets.
|
|
|
318
602
|
| `,.0f` | 1,234 | Numbers |
|
|
319
603
|
| `.2%` | 12.34% | Percentages |
|
|
320
604
|
| `.2s` | 1.2M | SI prefix |
|
|
321
|
-
|
|
322
|
-
## Theme (`yamchart.yaml`)
|
|
323
|
-
|
|
324
|
-
```yaml
|
|
325
|
-
theme:
|
|
326
|
-
palette: ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"]
|
|
327
|
-
gradient: false
|
|
328
|
-
opacity: 1.0
|
|
329
|
-
```
|
|
605
|
+
| `.2f` | 1.23 | Fixed decimals |
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
warning
|
|
8
8
|
} from "./chunk-HJVVHYVN.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
createConnector,
|
|
11
|
+
resolveConnection
|
|
12
|
+
} from "./chunk-UDJXQQKL.js";
|
|
13
|
+
import "./chunk-R7EULFJG.js";
|
|
14
|
+
import {
|
|
11
15
|
createTemplateContext,
|
|
12
16
|
expandDatePreset,
|
|
13
17
|
isDatePreset,
|
|
@@ -18,25 +22,11 @@ import {
|
|
|
18
22
|
import "./chunk-DGUM43GV.js";
|
|
19
23
|
|
|
20
24
|
// src/commands/test.ts
|
|
21
|
-
import { readFile, readdir
|
|
25
|
+
import { readFile, readdir } from "fs/promises";
|
|
22
26
|
import { join, extname } from "path";
|
|
23
|
-
import { parse as parseYaml } from "yaml";
|
|
24
27
|
async function testProject(projectDir, modelFilter, options) {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const projectConfig = parseYaml(projectContent);
|
|
28
|
-
const connName = options.connection || projectConfig.defaults?.connection;
|
|
29
|
-
if (!connName) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
"No connection specified. Use --connection or set defaults.connection in yamchart.yaml"
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
const connConfig = await loadConnectionConfig(projectDir, connName);
|
|
35
|
-
if (connConfig.type !== "duckdb") {
|
|
36
|
-
throw new Error(`Test command currently supports DuckDB only, got "${connConfig.type}"`);
|
|
37
|
-
}
|
|
38
|
-
const dbPathRaw = connConfig.config.path;
|
|
39
|
-
const dbPath = dbPathRaw.startsWith("/") ? dbPathRaw : join(projectDir, dbPathRaw);
|
|
28
|
+
const connection = await resolveConnection(projectDir, options.connection);
|
|
29
|
+
const connector = createConnector(connection, projectDir);
|
|
40
30
|
const modelsDir = join(projectDir, "models");
|
|
41
31
|
const allModels = await loadModels(modelsDir);
|
|
42
32
|
let modelsToTest = allModels;
|
|
@@ -59,32 +49,14 @@ async function testProject(projectDir, modelFilter, options) {
|
|
|
59
49
|
testInputs.push({ compiledSql: model.sql, metadata: model.metadata });
|
|
60
50
|
}
|
|
61
51
|
}
|
|
62
|
-
const connector = new DuckDBConnector({ path: dbPath });
|
|
63
52
|
try {
|
|
64
53
|
await connector.connect();
|
|
65
54
|
const suite = await runAll(testInputs, connector);
|
|
66
|
-
return { success: suite.failed === 0, suite, connectionName:
|
|
55
|
+
return { success: suite.failed === 0, suite, connectionName: connection.name };
|
|
67
56
|
} finally {
|
|
68
57
|
await connector.disconnect();
|
|
69
58
|
}
|
|
70
59
|
}
|
|
71
|
-
async function loadConnectionConfig(projectDir, connName) {
|
|
72
|
-
const yamlPath = join(projectDir, "connections", `${connName}.yaml`);
|
|
73
|
-
const ymlPath = join(projectDir, "connections", `${connName}.yml`);
|
|
74
|
-
let connPath = yamlPath;
|
|
75
|
-
try {
|
|
76
|
-
await access(yamlPath);
|
|
77
|
-
} catch {
|
|
78
|
-
try {
|
|
79
|
-
await access(ymlPath);
|
|
80
|
-
connPath = ymlPath;
|
|
81
|
-
} catch {
|
|
82
|
-
throw new Error(`Connection "${connName}" not found at connections/${connName}.yaml`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const connContent = await readFile(connPath, "utf-8");
|
|
86
|
-
return parseYaml(connContent);
|
|
87
|
-
}
|
|
88
60
|
function resolveDynamicDefault(value) {
|
|
89
61
|
const trimmed = value.trim().toLowerCase();
|
|
90
62
|
if (trimmed === "current_date()" || trimmed === "current_date" || trimmed === "now()" || trimmed.includes("current_date")) {
|
|
@@ -219,4 +191,4 @@ export {
|
|
|
219
191
|
formatTestOutput,
|
|
220
192
|
testProject
|
|
221
193
|
};
|
|
222
|
-
//# sourceMappingURL=test-
|
|
194
|
+
//# sourceMappingURL=test-3PFV2KAP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/commands/test.ts"],"sourcesContent":["import { readFile, readdir } from 'fs/promises';\nimport { join, extname } from 'path';\nimport {\n parseModelMetadata,\n runAll,\n renderTemplate,\n createTemplateContext,\n expandDatePreset,\n isDatePreset,\n type TestSuiteResult,\n type TestModelInput,\n} from '@yamchart/query';\nimport type { ModelMetadata } from '@yamchart/schema';\nimport * as output from '../utils/output.js';\nimport { resolveConnection, createConnector } from './connection-utils.js';\n\nexport interface TestOptions {\n connection?: string;\n json?: boolean;\n}\n\nexport interface TestResult {\n success: boolean;\n suite: TestSuiteResult;\n connectionName: string;\n}\n\nexport async function testProject(\n projectDir: string,\n modelFilter: string | undefined,\n options: TestOptions,\n): Promise<TestResult> {\n const connection = await resolveConnection(projectDir, options.connection);\n const connector = createConnector(connection, projectDir);\n\n const modelsDir = join(projectDir, 'models');\n const allModels = await loadModels(modelsDir);\n\n let modelsToTest = allModels;\n if (modelFilter) {\n modelsToTest = allModels.filter((m) => m.name === modelFilter);\n if (modelsToTest.length === 0) {\n throw new Error(`Model \"${modelFilter}\" not found`);\n }\n }\n\n // Build refs map: model name -> model name (identity for standalone execution)\n const refs: Record<string, string> = {};\n for (const m of allModels) {\n refs[m.name] = m.name;\n }\n\n const testInputs: TestModelInput[] = [];\n for (const model of modelsToTest) {\n try {\n const compiledSql = compileWithDefaults(model.sql, model.metadata, refs);\n testInputs.push({ compiledSql, metadata: model.metadata });\n } catch {\n // If compilation fails, include raw SQL so the error is reported during test execution\n testInputs.push({ compiledSql: model.sql, metadata: model.metadata });\n }\n }\n\n try {\n await connector.connect();\n const suite = await runAll(testInputs, connector);\n return { success: suite.failed === 0, suite, connectionName: connection.name };\n } finally {\n await connector.disconnect();\n }\n}\n\nfunction resolveDynamicDefault(value: string): string {\n const trimmed = value.trim().toLowerCase();\n if (\n trimmed === 'current_date()' ||\n trimmed === 'current_date' ||\n trimmed === 'now()' ||\n trimmed.includes('current_date')\n ) {\n return formatISODate(new Date());\n }\n return value;\n}\n\nfunction formatISODate(date: Date): string {\n const y = date.getFullYear();\n const m = String(date.getMonth() + 1).padStart(2, '0');\n const d = String(date.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nfunction compileWithDefaults(\n sql: string,\n metadata: ModelMetadata,\n refs: Record<string, string>,\n): string {\n const params: Record<string, unknown> = {};\n if (metadata.params) {\n for (const p of metadata.params) {\n if (p.default !== undefined) {\n params[p.name] = resolveDynamicDefault(p.default);\n }\n }\n }\n\n // Expand date presets into start_date/end_date\n if (typeof params.date_range === 'string' && isDatePreset(params.date_range)) {\n const range = expandDatePreset(params.date_range);\n if (range) {\n params.start_date = range.start_date;\n params.end_date = range.end_date;\n }\n }\n\n const context = createTemplateContext(params, refs);\n return renderTemplate(sql, context);\n}\n\ninterface LoadedModel {\n name: string;\n sql: string;\n metadata: ModelMetadata;\n}\n\nasync function loadModels(dir: string): Promise<LoadedModel[]> {\n const models: LoadedModel[] = [];\n\n let entries;\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n return models;\n }\n\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n const subModels = await loadModels(fullPath);\n models.push(...subModels);\n } else if (extname(entry.name) === '.sql') {\n const content = await readFile(fullPath, 'utf-8');\n try {\n const parsed = parseModelMetadata(content);\n models.push({ name: parsed.name, sql: parsed.sql, metadata: parsed });\n } catch {\n // Skip unparseable models\n }\n }\n }\n\n return models;\n}\n\nexport function formatTestOutput(result: TestResult, connectionName: string): void {\n const { suite } = result;\n\n const testedModels = suite.models.filter(\n (m) => m.schemaCheck || m.assertions.length > 0 || m.error,\n );\n\n output.header(`Testing ${suite.models.length} model(s) against ${connectionName}...`);\n\n for (const model of suite.models) {\n const hasChecks = model.schemaCheck || model.assertions.length > 0 || model.error;\n if (!hasChecks) continue;\n\n console.log(` ${model.modelName}`);\n\n if (model.error) {\n output.error(` error: ${model.error}`);\n continue;\n }\n\n if (model.schemaCheck) {\n formatSchemaCheck(model.schemaCheck);\n }\n\n for (const assertion of model.assertions) {\n formatAssertion(assertion);\n }\n\n output.newline();\n }\n\n // Summary line\n const totalChecks = suite.passed + suite.failed;\n const summary = `${testedModels.length} model(s), ${totalChecks} check(s): ${suite.passed} passed, ${suite.failed} failed`;\n const skippedSuffix = suite.skipped > 0 ? ` (${suite.skipped} skipped)` : '';\n const durationSuffix = ` (${suite.durationMs.toFixed(0)}ms)`;\n\n if (suite.failed === 0) {\n output.success(`${summary}${skippedSuffix}${durationSuffix}`);\n } else {\n output.error(`${summary}${skippedSuffix}${durationSuffix}`);\n }\n}\n\nfunction formatSchemaCheck(check: NonNullable<TestSuiteResult['models'][0]['schemaCheck']>): void {\n if (check.passed) {\n output.success(` schema: returns expected columns (${check.expectedColumns.join(', ')})`);\n return;\n }\n\n const issues: string[] = [];\n if (check.missingColumns.length > 0) {\n issues.push(`missing: ${check.missingColumns.join(', ')}`);\n }\n for (const m of check.typeMismatches) {\n issues.push(`${m.column}: expected ${m.expected}, got ${m.actual}`);\n }\n output.error(` schema: ${issues.join('; ')}`);\n}\n\nfunction formatAssertion(assertion: TestSuiteResult['models'][0]['assertions'][0]): void {\n const label = extractAssertionLabel(assertion.sql);\n\n if (assertion.warning) {\n output.warning(` ${label}`);\n output.detail(` ${assertion.warning}`);\n }\n\n if (assertion.error) {\n output.error(` test: ${label} -- error`);\n output.detail(` ${assertion.error}`);\n } else if (assertion.passed) {\n output.success(` test: ${label}`);\n } else {\n output.error(` test: ${label} -- ${assertion.violationCount} failing row(s)`);\n if (assertion.sampleViolations) {\n for (const row of assertion.sampleViolations.slice(0, 3)) {\n output.detail(` ${JSON.stringify(row)}`);\n }\n }\n }\n}\n\nfunction extractAssertionLabel(sql: string): string {\n const whereMatch = sql.match(/WHERE\\s+(.+?)$/i);\n if (whereMatch?.[1]) {\n const clause = whereMatch[1].trim();\n return clause.length > 60 ? clause.slice(0, 57) + '...' : clause;\n }\n return sql.length > 60 ? sql.slice(0, 57) + '...' : sql;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yamchart",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13",
|
|
4
4
|
"description": "Git-native business intelligence dashboards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"@yamchart/auth-local": "0.1.0",
|
|
67
67
|
"@yamchart/config": "0.1.2",
|
|
68
68
|
"@yamchart/query": "0.1.2",
|
|
69
|
-
"@yamchart/
|
|
70
|
-
"@yamchart/
|
|
69
|
+
"@yamchart/server": "0.1.2",
|
|
70
|
+
"@yamchart/schema": "0.1.2"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/commands/test.ts"],"sourcesContent":["import { readFile, readdir, access } from 'fs/promises';\nimport { join, extname } from 'path';\nimport { parse as parseYaml } from 'yaml';\nimport {\n parseModelMetadata,\n DuckDBConnector,\n runAll,\n renderTemplate,\n createTemplateContext,\n expandDatePreset,\n isDatePreset,\n type TestSuiteResult,\n type TestModelInput,\n} from '@yamchart/query';\nimport type { ModelMetadata } from '@yamchart/schema';\nimport * as output from '../utils/output.js';\n\nexport interface TestOptions {\n connection?: string;\n json?: boolean;\n}\n\nexport interface TestResult {\n success: boolean;\n suite: TestSuiteResult;\n connectionName: string;\n}\n\nexport async function testProject(\n projectDir: string,\n modelFilter: string | undefined,\n options: TestOptions,\n): Promise<TestResult> {\n const projectPath = join(projectDir, 'yamchart.yaml');\n const projectContent = await readFile(projectPath, 'utf-8');\n const projectConfig = parseYaml(projectContent) as {\n name: string;\n defaults?: { connection?: string };\n };\n\n const connName = options.connection || projectConfig.defaults?.connection;\n if (!connName) {\n throw new Error(\n 'No connection specified. Use --connection or set defaults.connection in yamchart.yaml',\n );\n }\n\n const connConfig = await loadConnectionConfig(projectDir, connName);\n\n if (connConfig.type !== 'duckdb') {\n throw new Error(`Test command currently supports DuckDB only, got \"${connConfig.type}\"`);\n }\n\n const dbPathRaw = connConfig.config.path as string;\n const dbPath = dbPathRaw.startsWith('/') ? dbPathRaw : join(projectDir, dbPathRaw);\n\n const modelsDir = join(projectDir, 'models');\n const allModels = await loadModels(modelsDir);\n\n let modelsToTest = allModels;\n if (modelFilter) {\n modelsToTest = allModels.filter((m) => m.name === modelFilter);\n if (modelsToTest.length === 0) {\n throw new Error(`Model \"${modelFilter}\" not found`);\n }\n }\n\n // Build refs map: model name -> model name (identity for standalone execution)\n const refs: Record<string, string> = {};\n for (const m of allModels) {\n refs[m.name] = m.name;\n }\n\n const testInputs: TestModelInput[] = [];\n for (const model of modelsToTest) {\n try {\n const compiledSql = compileWithDefaults(model.sql, model.metadata, refs);\n testInputs.push({ compiledSql, metadata: model.metadata });\n } catch {\n // If compilation fails, include raw SQL so the error is reported during test execution\n testInputs.push({ compiledSql: model.sql, metadata: model.metadata });\n }\n }\n\n const connector = new DuckDBConnector({ path: dbPath });\n try {\n await connector.connect();\n const suite = await runAll(testInputs, connector);\n return { success: suite.failed === 0, suite, connectionName: connName };\n } finally {\n await connector.disconnect();\n }\n}\n\nasync function loadConnectionConfig(\n projectDir: string,\n connName: string,\n): Promise<{ type: string; config: Record<string, unknown> }> {\n const yamlPath = join(projectDir, 'connections', `${connName}.yaml`);\n const ymlPath = join(projectDir, 'connections', `${connName}.yml`);\n\n let connPath = yamlPath;\n try {\n await access(yamlPath);\n } catch {\n try {\n await access(ymlPath);\n connPath = ymlPath;\n } catch {\n throw new Error(`Connection \"${connName}\" not found at connections/${connName}.yaml`);\n }\n }\n\n const connContent = await readFile(connPath, 'utf-8');\n return parseYaml(connContent) as { type: string; config: Record<string, unknown> };\n}\n\nfunction resolveDynamicDefault(value: string): string {\n const trimmed = value.trim().toLowerCase();\n if (\n trimmed === 'current_date()' ||\n trimmed === 'current_date' ||\n trimmed === 'now()' ||\n trimmed.includes('current_date')\n ) {\n return formatISODate(new Date());\n }\n return value;\n}\n\nfunction formatISODate(date: Date): string {\n const y = date.getFullYear();\n const m = String(date.getMonth() + 1).padStart(2, '0');\n const d = String(date.getDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nfunction compileWithDefaults(\n sql: string,\n metadata: ModelMetadata,\n refs: Record<string, string>,\n): string {\n const params: Record<string, unknown> = {};\n if (metadata.params) {\n for (const p of metadata.params) {\n if (p.default !== undefined) {\n params[p.name] = resolveDynamicDefault(p.default);\n }\n }\n }\n\n // Expand date presets into start_date/end_date\n if (typeof params.date_range === 'string' && isDatePreset(params.date_range)) {\n const range = expandDatePreset(params.date_range);\n if (range) {\n params.start_date = range.start_date;\n params.end_date = range.end_date;\n }\n }\n\n const context = createTemplateContext(params, refs);\n return renderTemplate(sql, context);\n}\n\ninterface LoadedModel {\n name: string;\n sql: string;\n metadata: ModelMetadata;\n}\n\nasync function loadModels(dir: string): Promise<LoadedModel[]> {\n const models: LoadedModel[] = [];\n\n let entries;\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n return models;\n }\n\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n const subModels = await loadModels(fullPath);\n models.push(...subModels);\n } else if (extname(entry.name) === '.sql') {\n const content = await readFile(fullPath, 'utf-8');\n try {\n const parsed = parseModelMetadata(content);\n models.push({ name: parsed.name, sql: parsed.sql, metadata: parsed });\n } catch {\n // Skip unparseable models\n }\n }\n }\n\n return models;\n}\n\nexport function formatTestOutput(result: TestResult, connectionName: string): void {\n const { suite } = result;\n\n const testedModels = suite.models.filter(\n (m) => m.schemaCheck || m.assertions.length > 0 || m.error,\n );\n\n output.header(`Testing ${suite.models.length} model(s) against ${connectionName}...`);\n\n for (const model of suite.models) {\n const hasChecks = model.schemaCheck || model.assertions.length > 0 || model.error;\n if (!hasChecks) continue;\n\n console.log(` ${model.modelName}`);\n\n if (model.error) {\n output.error(` error: ${model.error}`);\n continue;\n }\n\n if (model.schemaCheck) {\n formatSchemaCheck(model.schemaCheck);\n }\n\n for (const assertion of model.assertions) {\n formatAssertion(assertion);\n }\n\n output.newline();\n }\n\n // Summary line\n const totalChecks = suite.passed + suite.failed;\n const summary = `${testedModels.length} model(s), ${totalChecks} check(s): ${suite.passed} passed, ${suite.failed} failed`;\n const skippedSuffix = suite.skipped > 0 ? ` (${suite.skipped} skipped)` : '';\n const durationSuffix = ` (${suite.durationMs.toFixed(0)}ms)`;\n\n if (suite.failed === 0) {\n output.success(`${summary}${skippedSuffix}${durationSuffix}`);\n } else {\n output.error(`${summary}${skippedSuffix}${durationSuffix}`);\n }\n}\n\nfunction formatSchemaCheck(check: NonNullable<TestSuiteResult['models'][0]['schemaCheck']>): void {\n if (check.passed) {\n output.success(` schema: returns expected columns (${check.expectedColumns.join(', ')})`);\n return;\n }\n\n const issues: string[] = [];\n if (check.missingColumns.length > 0) {\n issues.push(`missing: ${check.missingColumns.join(', ')}`);\n }\n for (const m of check.typeMismatches) {\n issues.push(`${m.column}: expected ${m.expected}, got ${m.actual}`);\n }\n output.error(` schema: ${issues.join('; ')}`);\n}\n\nfunction formatAssertion(assertion: TestSuiteResult['models'][0]['assertions'][0]): void {\n const label = extractAssertionLabel(assertion.sql);\n\n if (assertion.warning) {\n output.warning(` ${label}`);\n output.detail(` ${assertion.warning}`);\n }\n\n if (assertion.error) {\n output.error(` test: ${label} -- error`);\n output.detail(` ${assertion.error}`);\n } else if (assertion.passed) {\n output.success(` test: ${label}`);\n } else {\n output.error(` test: ${label} -- ${assertion.violationCount} failing row(s)`);\n if (assertion.sampleViolations) {\n for (const row of assertion.sampleViolations.slice(0, 3)) {\n output.detail(` ${JSON.stringify(row)}`);\n }\n }\n }\n}\n\nfunction extractAssertionLabel(sql: string): string {\n const whereMatch = sql.match(/WHERE\\s+(.+?)$/i);\n if (whereMatch?.[1]) {\n const clause = whereMatch[1].trim();\n return clause.length > 60 ? clause.slice(0, 57) + '...' : clause;\n }\n return sql.length > 60 ? sql.slice(0, 57) + '...' : sql;\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|