dirsql 0.3.94__tar.gz → 0.3.96__tar.gz

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.
Files changed (139) hide show
  1. {dirsql-0.3.94 → dirsql-0.3.96}/Cargo.lock +1 -1
  2. {dirsql-0.3.94 → dirsql-0.3.96}/PKG-INFO +1 -1
  3. {dirsql-0.3.94/packages/python → dirsql-0.3.96}/docs/howto/define-tables.md +4 -3
  4. {dirsql-0.3.94/packages/rust → dirsql-0.3.96}/docs/reference/config.md +1 -1
  5. {dirsql-0.3.94/packages/rust → dirsql-0.3.96}/docs/reference/sdk.md +3 -1
  6. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/Cargo.toml +1 -1
  7. {dirsql-0.3.94/packages/rust → dirsql-0.3.96/packages/python}/docs/howto/define-tables.md +4 -3
  8. {dirsql-0.3.94 → dirsql-0.3.96/packages/python}/docs/reference/config.md +1 -1
  9. {dirsql-0.3.94 → dirsql-0.3.96/packages/python}/docs/reference/sdk.md +3 -1
  10. dirsql-0.3.96/packages/python/e2e-attestations/claude-github-issue-580-d0jd9u.json +7 -0
  11. dirsql-0.3.96/packages/python/e2e-attestations/claude-open-issues-review-le8hm5-red-546.json +7 -0
  12. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/benches/matcher_bench.rs +2 -2
  13. {dirsql-0.3.94 → dirsql-0.3.96/packages/rust}/docs/howto/define-tables.md +4 -3
  14. {dirsql-0.3.94/packages/python → dirsql-0.3.96/packages/rust}/docs/reference/config.md +1 -1
  15. {dirsql-0.3.94/packages/python → dirsql-0.3.96/packages/rust}/docs/reference/sdk.md +3 -1
  16. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/bin/dirsql.rs +4 -4
  17. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/execute.rs +38 -21
  18. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/mod.rs +47 -31
  19. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/router.rs +10 -10
  20. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/lib.rs +61 -43
  21. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/matcher.rs +146 -96
  22. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/persist.rs +84 -16
  23. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/scanner.rs +4 -2
  24. {dirsql-0.3.94 → dirsql-0.3.96}/Cargo.toml +0 -0
  25. {dirsql-0.3.94 → dirsql-0.3.96}/README.md +0 -0
  26. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/__init__.py +0 -0
  27. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/_async.py +0 -0
  28. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/_dirsql.pyi +0 -0
  29. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/cli/__init__.py +0 -0
  30. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/cli/binary_path.py +0 -0
  31. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/cli/is_windows.py +0 -0
  32. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/cli/main.py +0 -0
  33. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/cli/resolve_config_extensions.py +0 -0
  34. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/py.typed +0 -0
  35. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/resolve_config_extensions.py +0 -0
  36. {dirsql-0.3.94 → dirsql-0.3.96}/dirsql/resolve_extension.py +0 -0
  37. {dirsql-0.3.94 → dirsql-0.3.96}/docs/.claude/CLAUDE.md +0 -0
  38. {dirsql-0.3.94 → dirsql-0.3.96}/docs/.vitepress/config.ts +0 -0
  39. {dirsql-0.3.94 → dirsql-0.3.96}/docs/.vitepress/theme/index.ts +0 -0
  40. {dirsql-0.3.94 → dirsql-0.3.96}/docs/.vitepress/theme/lang.ts +0 -0
  41. {dirsql-0.3.94 → dirsql-0.3.96}/docs/AGENTS.md +0 -0
  42. {dirsql-0.3.94 → dirsql-0.3.96}/docs/explanation.md +0 -0
  43. {dirsql-0.3.94 → dirsql-0.3.96}/docs/getting-started.md +0 -0
  44. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/columns-from-paths.md +0 -0
  45. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/embed.md +0 -0
  46. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/extract-from-contents.md +0 -0
  47. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/load-extension.md +0 -0
  48. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/persist.md +0 -0
  49. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/react-to-changes.md +0 -0
  50. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/search-by-meaning.md +0 -0
  51. {dirsql-0.3.94 → dirsql-0.3.96}/docs/howto/skip-files.md +0 -0
  52. {dirsql-0.3.94 → dirsql-0.3.96}/docs/index.md +0 -0
  53. {dirsql-0.3.94 → dirsql-0.3.96}/docs/migrations.md +0 -0
  54. {dirsql-0.3.94 → dirsql-0.3.96}/docs/package.json +0 -0
  55. {dirsql-0.3.94 → dirsql-0.3.96}/docs/playwright.config.ts +0 -0
  56. {dirsql-0.3.94 → dirsql-0.3.96}/docs/pnpm-lock.yaml +0 -0
  57. {dirsql-0.3.94 → dirsql-0.3.96}/docs/pnpm-workspace.yaml +0 -0
  58. {dirsql-0.3.94 → dirsql-0.3.96}/docs/reference/cli.md +0 -0
  59. {dirsql-0.3.94 → dirsql-0.3.96}/docs/reference/columns.md +0 -0
  60. {dirsql-0.3.94 → dirsql-0.3.96}/docs/reference/hooks.md +0 -0
  61. {dirsql-0.3.94 → dirsql-0.3.96}/docs/reference/http-api.md +0 -0
  62. {dirsql-0.3.94 → dirsql-0.3.96}/docs/tests/integration/home.spec.ts +0 -0
  63. {dirsql-0.3.94 → dirsql-0.3.96}/docs/tests/integration/language-flag.spec.ts +0 -0
  64. {dirsql-0.3.94 → dirsql-0.3.96}/docs/tests/integration/sidebar.spec.ts +0 -0
  65. {dirsql-0.3.94 → dirsql-0.3.96}/docs/tests/unit/config.test.ts +0 -0
  66. {dirsql-0.3.94 → dirsql-0.3.96}/docs/tests/unit/lang.test.ts +0 -0
  67. {dirsql-0.3.94 → dirsql-0.3.96}/docs/vitest.config.ts +0 -0
  68. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/README.md +0 -0
  69. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/conftest.py +0 -0
  70. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/.claude/CLAUDE.md +0 -0
  71. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/.vitepress/config.ts +0 -0
  72. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  73. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  74. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/AGENTS.md +0 -0
  75. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/explanation.md +0 -0
  76. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/getting-started.md +0 -0
  77. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/columns-from-paths.md +0 -0
  78. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/embed.md +0 -0
  79. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/extract-from-contents.md +0 -0
  80. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/load-extension.md +0 -0
  81. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/persist.md +0 -0
  82. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/react-to-changes.md +0 -0
  83. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/search-by-meaning.md +0 -0
  84. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/howto/skip-files.md +0 -0
  85. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/index.md +0 -0
  86. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/migrations.md +0 -0
  87. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/package.json +0 -0
  88. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/playwright.config.ts +0 -0
  89. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/pnpm-lock.yaml +0 -0
  90. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/pnpm-workspace.yaml +0 -0
  91. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/reference/cli.md +0 -0
  92. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/reference/columns.md +0 -0
  93. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/reference/hooks.md +0 -0
  94. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/reference/http-api.md +0 -0
  95. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  96. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  97. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  98. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/tests/unit/config.test.ts +0 -0
  99. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  100. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/docs/vitest.config.ts +0 -0
  101. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/e2e-attestations/claude-open-issues-review-le8hm5-red-553.json +0 -0
  102. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/src/lib.rs +0 -0
  103. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/__init__.py +0 -0
  104. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/conftest.py +0 -0
  105. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/e2e/__init__.py +0 -0
  106. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/integration/__init__.py +0 -0
  107. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/integration/binding/__init__.py +0 -0
  108. {dirsql-0.3.94 → dirsql-0.3.96}/packages/python/tests/integration/hermetic/__init__.py +0 -0
  109. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/Cargo.toml +0 -0
  110. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/README.md +0 -0
  111. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/benches/db_bench.rs +0 -0
  112. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/benches/differ_bench.rs +0 -0
  113. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/benches/scanner_bench.rs +0 -0
  114. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/explanation.md +0 -0
  115. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/getting-started.md +0 -0
  116. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  117. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/embed.md +0 -0
  118. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  119. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/load-extension.md +0 -0
  120. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/persist.md +0 -0
  121. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/react-to-changes.md +0 -0
  122. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  123. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/howto/skip-files.md +0 -0
  124. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/index.md +0 -0
  125. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/migrations.md +0 -0
  126. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/reference/cli.md +0 -0
  127. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/reference/columns.md +0 -0
  128. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/reference/hooks.md +0 -0
  129. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/docs/reference/http-api.md +0 -0
  130. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/init.rs +0 -0
  131. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/serialize.rs +0 -0
  132. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/cli/server.rs +0 -0
  133. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/command.rs +0 -0
  134. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/config.rs +0 -0
  135. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/db.rs +0 -0
  136. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/default_config.toml +0 -0
  137. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/differ.rs +0 -0
  138. {dirsql-0.3.94 → dirsql-0.3.96}/packages/rust/src/watcher.rs +0 -0
  139. {dirsql-0.3.94 → dirsql-0.3.96}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.94"
503
+ version = "0.3.96"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.94
3
+ Version: 0.3.96
4
4
  Summary: Ephemeral SQL index over a local directory
5
5
  Keywords: sql,filesystem,directory,sqlite,index
6
6
  Author: Kevin Scott
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
4
4
  # pypi/maturin handler can rewrite it via `write-version` before
5
5
  # `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
6
6
  # and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
7
- version = "0.3.94"
7
+ version = "0.3.96"
8
8
  # abi3 note: the `abi3-py311` pyo3 feature (below) builds ONE stable-ABI
9
9
  # `cp311-abi3` wheel per platform that loads on every CPython >= 3.11
10
10
  # (matching `pyproject.toml`'s `requires-python = ">=3.11"`), instead of
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run python -m pytest tests/e2e/ -x -q",
3
+ "ran_at": 1783944230,
4
+ "exit_code": 0,
5
+ "commit": "f6b1948a6d2de0b769ca428a802f52899f85c4b9",
6
+ "branch": "claude/github-issue-580-d0jd9u"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run python -m pytest tests/e2e/ -x -q",
3
+ "ran_at": 1783942299,
4
+ "exit_code": 0,
5
+ "commit": "46b5f8c7f7b10a85f62c2098e1d43133ccf8101e",
6
+ "branch": "claude/open-issues-review-le8hm5-red-546"
7
+ }
@@ -20,7 +20,7 @@ fn make_test_paths(count: usize) -> Vec<String> {
20
20
  }
21
21
 
22
22
  fn bench_match_file(c: &mut Criterion) {
23
- let mut group = c.benchmark_group("matcher/match_file");
23
+ let mut group = c.benchmark_group("matcher/match_all");
24
24
  for pattern_count in [1, 10, 50] {
25
25
  let matcher = make_matcher(pattern_count);
26
26
  let paths = make_test_paths(100);
@@ -30,7 +30,7 @@ fn bench_match_file(c: &mut Criterion) {
30
30
  |b, _| {
31
31
  b.iter(|| {
32
32
  for p in &paths {
33
- matcher.match_file(Path::new(p));
33
+ matcher.match_all(Path::new(p));
34
34
  }
35
35
  });
36
36
  },
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -138,8 +138,8 @@ async fn main() -> ExitCode {
138
138
  /// stderr with a non-zero exit.
139
139
  async fn run_query(cli: &Cli, args: QueryArgs) -> ExitCode {
140
140
  let state = load_state(cli);
141
- let pre_query = load_pre_query(cli);
142
- let post_query = load_post_query(cli);
141
+ let pre_query: Vec<PreQuery> = load_pre_query(cli).into_iter().collect();
142
+ let post_query: Vec<PostQuery> = load_post_query(cli).into_iter().collect();
143
143
  // Same default the server binds with; the pipeline enforces it.
144
144
  let timeout = ServerConfig::default().query_timeout;
145
145
 
@@ -147,8 +147,8 @@ async fn run_query(cli: &Cli, args: QueryArgs) -> ExitCode {
147
147
  &state,
148
148
  query_body(&args.sql),
149
149
  timeout,
150
- pre_query.as_ref(),
151
- post_query.as_ref(),
150
+ &pre_query,
151
+ &post_query,
152
152
  )
153
153
  .await
154
154
  {
@@ -68,14 +68,22 @@ pub async fn execute_query(
68
68
  state: &AppState,
69
69
  raw_body: String,
70
70
  timeout: Duration,
71
- pre_query: Option<&PreQuery>,
72
- post_query: Option<&PostQuery>,
71
+ pre_query: &[PreQuery],
72
+ post_query: &[PostQuery],
73
73
  ) -> Result<Value, QueryFailure> {
74
- // Resolve the SQL to run. With a `pre-query` hook the raw body is
75
- // rewritten by the command; without one it is parsed as `{"sql": …}`.
76
- let sql = match pre_query {
77
- Some(pq) => run_pre_query(pq, raw_body).await?,
78
- None => parse_sql_body(&raw_body)?,
74
+ // Resolve the SQL to run. A `pre-query` chain pipes the raw body FIFO
75
+ // through each stage (body stage₁ SQL), each stage receiving the
76
+ // previous stage's output as its `{args}`; with no stage the body is parsed
77
+ // as `{"sql": …}`. A failing stage fails the request (its `?` short-circuits
78
+ // the chain).
79
+ let sql = if pre_query.is_empty() {
80
+ parse_sql_body(&raw_body)?
81
+ } else {
82
+ let mut payload = raw_body;
83
+ for pq in pre_query {
84
+ payload = run_pre_query(pq, payload).await?;
85
+ }
86
+ payload
79
87
  };
80
88
 
81
89
  let db = require_ready(state)?;
@@ -86,9 +94,17 @@ pub async fn execute_query(
86
94
  match join {
87
95
  Ok(Ok(Ok(rows))) => {
88
96
  let rows_json = rows_to_json(&rows);
89
- match post_query {
90
- Some(pq) => run_post_query(pq, rows_json).await,
91
- None => Ok(Value::Array(rows_json)),
97
+ // A `post-query` chain pipes the rows FIFO through each stage
98
+ // (rows stage₁ → … → response), each stage receiving the previous
99
+ // stage's output; with no stage the rows return as-is.
100
+ if post_query.is_empty() {
101
+ Ok(Value::Array(rows_json))
102
+ } else {
103
+ let mut payload = Value::Array(rows_json);
104
+ for pq in post_query {
105
+ payload = run_post_query(pq, payload).await?;
106
+ }
107
+ Ok(payload)
92
108
  }
93
109
  }
94
110
  Ok(Ok(Err(err))) => Err(classify_query_error(err)),
@@ -162,18 +178,19 @@ async fn run_pre_query(pq: &PreQuery, raw_body: String) -> Result<String, QueryF
162
178
  .map_err(|err| QueryFailure::Internal(err.to_string()))
163
179
  }
164
180
 
165
- /// Run the `post-query` hook over a successful result set and return the JSON
166
- /// body it prints. The rows are serialized to a JSON array and delivered two
167
- /// ways: always on the child's stdin (unbounded, injection-safe), and as the
168
- /// `{args}` placeholder when the payload is within [`POST_QUERY_ARGS_MAX`]
169
- /// (beyond that `{args}` is emptied and a warning names the size, directing
170
- /// the operator to stdin never silent truncation). The command's last
171
- /// non-empty stdout line is parsed as JSON and returned as the result;
172
- /// anything that isn't valid JSON, or any failure (non-zero exit, timeout,
173
- /// spawn error), maps to `Internal`.
174
- async fn run_post_query(pq: &PostQuery, rows: Vec<Value>) -> Result<Value, QueryFailure> {
181
+ /// Run one `post-query` stage over its `input` JSON and return the JSON body it
182
+ /// prints. `input` is the serialized result rows for the first stage, or the
183
+ /// previous stage's output thereafter. It is serialized and delivered two ways:
184
+ /// always on the child's stdin (unbounded, injection-safe), and as the `{args}`
185
+ /// placeholder when the payload is within [`POST_QUERY_ARGS_MAX`] (beyond that
186
+ /// `{args}` is emptied and a warning names the size, directing the operator to
187
+ /// stdin never silent truncation). The command's last non-empty stdout line
188
+ /// is parsed as JSON and returned as the result; anything that isn't valid
189
+ /// JSON, or any failure (non-zero exit, timeout, spawn error), maps to
190
+ /// `Internal`.
191
+ async fn run_post_query(pq: &PostQuery, input: Value) -> Result<Value, QueryFailure> {
175
192
  let payload =
176
- serde_json::to_string(&rows).map_err(|err| QueryFailure::Internal(err.to_string()))?;
193
+ serde_json::to_string(&input).map_err(|err| QueryFailure::Internal(err.to_string()))?;
177
194
  let command = pq.command.clone();
178
195
  let config_dir = pq.config_dir.clone();
179
196
  let timeout = pq.timeout;
@@ -127,12 +127,17 @@ pub struct ServerConfig {
127
127
  pub host: String,
128
128
  pub port: u16,
129
129
  pub query_timeout: Duration,
130
- /// Optional server-wide `pre-query` command. When `None` (the default),
131
- /// `POST /query` parses its body as `{"sql": …}`.
132
- pub pre_query: Option<PreQuery>,
133
- /// Optional server-wide `post-query` command. When `None` (the default),
134
- /// `POST /query` returns the result rows as-is.
135
- pub post_query: Option<PostQuery>,
130
+ /// Ordered `pre-query` command chain. Empty (the default) means
131
+ /// `POST /query` parses its body as `{"sql": …}`; otherwise the raw body is
132
+ /// piped through each stage in registration order (body → stage₁ → … → SQL),
133
+ /// each stage receiving the previous stage's output as its `{args}`.
134
+ pub pre_query: Vec<PreQuery>,
135
+ /// Ordered `post-query` command chain. Empty (the default) means
136
+ /// `POST /query` returns the result rows as-is; otherwise the rows are piped
137
+ /// through each stage in registration order (rows → stage₁ → … → response),
138
+ /// each stage receiving the previous stage's output as its `{args}` (and on
139
+ /// stdin).
140
+ pub post_query: Vec<PostQuery>,
136
141
  }
137
142
 
138
143
  impl ServerConfig {
@@ -143,8 +148,8 @@ impl ServerConfig {
143
148
  host: "localhost".into(),
144
149
  port: 0,
145
150
  query_timeout: Duration::from_secs(30),
146
- pre_query: None,
147
- post_query: None,
151
+ pre_query: Vec::new(),
152
+ post_query: Vec::new(),
148
153
  }
149
154
  }
150
155
 
@@ -154,8 +159,8 @@ impl ServerConfig {
154
159
  host: host.into(),
155
160
  port,
156
161
  query_timeout: Duration::from_secs(30),
157
- pre_query: None,
158
- post_query: None,
162
+ pre_query: Vec::new(),
163
+ post_query: Vec::new(),
159
164
  }
160
165
  }
161
166
 
@@ -166,19 +171,23 @@ impl ServerConfig {
166
171
  self
167
172
  }
168
173
 
169
- /// Attach a server-wide [`PreQuery`] hook. With it set, `POST /query`
170
- /// passes the raw request body to the command and runs the SQL it prints
171
- /// instead of parsing the body as `{"sql": …}`.
174
+ /// Append a [`PreQuery`] stage to the chain. Stages run in registration
175
+ /// order (body stage₁ → SQL); the first stage receives the raw
176
+ /// request body and each subsequent stage receives the previous stage's
177
+ /// output, the last stage's output being the SQL to run. With no stage the
178
+ /// body is parsed as `{"sql": …}`.
172
179
  pub fn with_pre_query(mut self, pre_query: PreQuery) -> Self {
173
- self.pre_query = Some(pre_query);
180
+ self.pre_query.push(pre_query);
174
181
  self
175
182
  }
176
183
 
177
- /// Attach a server-wide [`PostQuery`] hook. With it set, `POST /query`
178
- /// hands each successful result set to the command and returns the JSON
179
- /// body it prints instead of returning the rows as-is.
184
+ /// Append a [`PostQuery`] stage to the chain. Stages run in registration
185
+ /// order (rows stage₁ → response); the first stage receives the
186
+ /// serialized result rows and each subsequent stage receives the previous
187
+ /// stage's output, the last stage's output being the response body. With no
188
+ /// stage the rows are returned as-is.
180
189
  pub fn with_post_query(mut self, post_query: PostQuery) -> Self {
181
- self.post_query = Some(post_query);
190
+ self.post_query.push(post_query);
182
191
  self
183
192
  }
184
193
  }
@@ -276,8 +285,8 @@ mod tests {
276
285
  assert_eq!(cfg.host, "localhost");
277
286
  assert_eq!(cfg.port, 7117);
278
287
  assert_eq!(cfg.query_timeout, Duration::from_secs(30));
279
- assert!(cfg.pre_query.is_none());
280
- assert!(cfg.post_query.is_none());
288
+ assert!(cfg.pre_query.is_empty());
289
+ assert!(cfg.post_query.is_empty());
281
290
  }
282
291
 
283
292
  #[test]
@@ -295,11 +304,15 @@ mod tests {
295
304
  }
296
305
 
297
306
  #[test]
298
- fn with_pre_query_sets_the_hook() {
299
- let cfg = ServerConfig::ephemeral().with_pre_query(PreQuery::new("cmd {args}", "/proj"));
300
- let pq = cfg.pre_query.expect("hook must be set");
301
- assert_eq!(pq.command, "cmd {args}");
302
- assert_eq!(pq.config_dir, PathBuf::from("/proj"));
307
+ fn with_pre_query_appends_stages_in_order() {
308
+ let cfg = ServerConfig::ephemeral()
309
+ .with_pre_query(PreQuery::new("first {args}", "/a"))
310
+ .with_pre_query(PreQuery::new("second {args}", "/b"));
311
+ assert_eq!(cfg.pre_query.len(), 2);
312
+ assert_eq!(cfg.pre_query[0].command, "first {args}");
313
+ assert_eq!(cfg.pre_query[0].config_dir, PathBuf::from("/a"));
314
+ assert_eq!(cfg.pre_query[1].command, "second {args}");
315
+ assert_eq!(cfg.pre_query[1].config_dir, PathBuf::from("/b"));
303
316
  }
304
317
 
305
318
  #[test]
@@ -317,12 +330,15 @@ mod tests {
317
330
  }
318
331
 
319
332
  #[test]
320
- fn with_post_query_sets_the_hook() {
321
- let cfg =
322
- ServerConfig::ephemeral().with_post_query(PostQuery::new("reshape {args}", "/proj"));
323
- let pq = cfg.post_query.expect("hook must be set");
324
- assert_eq!(pq.command, "reshape {args}");
325
- assert_eq!(pq.config_dir, PathBuf::from("/proj"));
333
+ fn with_post_query_appends_stages_in_order() {
334
+ let cfg = ServerConfig::ephemeral()
335
+ .with_post_query(PostQuery::new("first {args}", "/a"))
336
+ .with_post_query(PostQuery::new("second {args}", "/b"));
337
+ assert_eq!(cfg.post_query.len(), 2);
338
+ assert_eq!(cfg.post_query[0].command, "first {args}");
339
+ assert_eq!(cfg.post_query[0].config_dir, PathBuf::from("/a"));
340
+ assert_eq!(cfg.post_query[1].command, "second {args}");
341
+ assert_eq!(cfg.post_query[1].config_dir, PathBuf::from("/b"));
326
342
  }
327
343
 
328
344
  #[test]
@@ -28,14 +28,14 @@ pub(super) struct AppContext {
28
28
  pub events: broadcast::Sender<String>,
29
29
  pub cancel: watch::Receiver<bool>,
30
30
  pub query_timeout: Duration,
31
- /// Optional server-wide `pre-query` hook. When `Some`, `POST /query`
32
- /// rewrites the request body through the command; when `None`, the body
33
- /// is parsed as `{"sql":}`.
34
- pub pre_query: Option<PreQuery>,
35
- /// Optional server-wide `post-query` hook. When `Some`, a successful
36
- /// `POST /query` result set is reshaped by the command before responding;
37
- /// when `None`, the rows are returned as-is.
38
- pub post_query: Option<PostQuery>,
31
+ /// Ordered `pre-query` command chain. Empty means `POST /query` parses the
32
+ /// body as `{"sql": …}`; otherwise the raw body is piped through each stage
33
+ /// in order (body stage₁ → → SQL).
34
+ pub pre_query: Vec<PreQuery>,
35
+ /// Ordered `post-query` command chain. Empty means the rows are returned
36
+ /// as-is; otherwise a successful result set is piped through each stage in
37
+ /// order (rows stage₁ → response).
38
+ pub post_query: Vec<PostQuery>,
39
39
  }
40
40
 
41
41
  pub(super) type SharedCtx = Arc<AppContext>;
@@ -58,8 +58,8 @@ async fn handle_query(State(ctx): State<SharedCtx>, body: String) -> Response {
58
58
  &ctx.state,
59
59
  body,
60
60
  ctx.query_timeout,
61
- ctx.pre_query.as_ref(),
62
- ctx.post_query.as_ref(),
61
+ &ctx.pre_query,
62
+ &ctx.post_query,
63
63
  )
64
64
  .await
65
65
  {