hecks 1.1.0 → 1.2.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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +54 -5
- data/lib/hecks/projections/glossary.rb +374 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/value/coercion.rb +64 -1
- data/lib/hecks/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d66cdbdb775a92f3cfcf3f626637cffd6fb81915d7edbcccfe21e1b7c980606
|
|
4
|
+
data.tar.gz: a43786075ea299d26d6d1f23075cf2176fdad15b7efa235234c4a6e321ec15f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac3883e9e6ebeef723718dfa39785d2dcdd0e57e613f6ca21a9519040eb11278f22eedbe597965d1b28e3dfa08b98ac2d0a989686cd7b8aec0d8d97dd476acec
|
|
7
|
+
data.tar.gz: f610074205ca1a39121169ef02109445b60ca66bf517c1a8351e8c761a1863fccfe0f9b41075fa00cc8225c017c82eefdb47c38566469fe378d0879aec115860
|
|
@@ -20,11 +20,14 @@ module Hecks
|
|
|
20
20
|
return yield unless @db.transaction_status == PG::PQTRANS_IDLE
|
|
21
21
|
|
|
22
22
|
@db.transaction(&)
|
|
23
|
+
rescue PG::ConnectionBad
|
|
24
|
+
reconnect!
|
|
25
|
+
raise
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def outbox_enqueue(rows)
|
|
26
29
|
rows.filter_map do |row|
|
|
27
|
-
result =
|
|
30
|
+
result = pg_exec_params(
|
|
28
31
|
"INSERT INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, status, attempts) " \
|
|
29
32
|
"VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0) ON CONFLICT (delivery_id) DO NOTHING RETURNING id",
|
|
30
33
|
[row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer, JSON.generate(row.event)]
|
|
@@ -38,7 +41,7 @@ module Hecks
|
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
|
|
41
|
-
|
|
44
|
+
pg_exec_params(
|
|
42
45
|
"UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = now() " \
|
|
43
46
|
"WHERE id = $1 AND status = 'pending'",
|
|
44
47
|
[id]
|
|
@@ -46,7 +49,7 @@ module Hecks
|
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
|
|
49
|
-
|
|
52
|
+
pg_exec_params(
|
|
50
53
|
"UPDATE hecks_outbox SET status = $2, error = $3, settled_at = now() WHERE id = $1",
|
|
51
54
|
[id, status.to_s, error]
|
|
52
55
|
).cmd_tuples == 1
|
|
@@ -59,7 +62,7 @@ module Hecks
|
|
|
59
62
|
sql << " AND status = $2"
|
|
60
63
|
binds << status.to_s
|
|
61
64
|
end
|
|
62
|
-
|
|
65
|
+
pg_exec_params("#{sql} ORDER BY id", binds).map do |row|
|
|
63
66
|
Runtime::Outbox::Row.new(
|
|
64
67
|
id: row["id"].to_i, delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
|
|
65
68
|
domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Adapters
|
|
3
|
+
# SELF-HEALING CONNECTION — shared verbatim by `Postgres` and the era
|
|
4
|
+
# plugin's `PostgresEra`, the same way `PostgresOutbox` (outbox.rb) is:
|
|
5
|
+
# nothing here is lineage-specific, and `PostgresOutbox`'s own
|
|
6
|
+
# `@db.exec*` calls route through this module's `pg_exec`/
|
|
7
|
+
# `pg_exec_params` too, since both classes mix it in. Needs `@db` (a
|
|
8
|
+
# `PG::Connection`), `@aggregate`, and `@settings` from the including
|
|
9
|
+
# class — the same three `self.class.connect_for(@aggregate.name,
|
|
10
|
+
# @settings)` already needs to build one in the first place.
|
|
11
|
+
#
|
|
12
|
+
# A backend killed out from under an adapter (a DBA's own
|
|
13
|
+
# `pg_terminate_backend`, a load balancer's failover, a restart) —
|
|
14
|
+
# chaos-tested against the plain `Postgres` adapter: `PG::ConnectionBad`
|
|
15
|
+
# on the query that hit it, and PERMANENTLY on every query after,
|
|
16
|
+
# since nothing ever replaced `@db` with a live connection.
|
|
17
|
+
# `pg_exec`/`pg_exec_params` are the two primitives every other method
|
|
18
|
+
# in either class funnels through — wrapping them here, once,
|
|
19
|
+
# self-heals `@db` for the NEXT caller. THE CURRENT CALL STILL
|
|
20
|
+
# RAISES — reconnecting cannot tell a caller whether ITS OWN write
|
|
21
|
+
# reached the server before the connection died, so silently
|
|
22
|
+
# retrying it here could silently double it; that ambiguity is
|
|
23
|
+
# exactly why `Runtime::SagaInterpreter`'s own defect-retry exists
|
|
24
|
+
# ONE LAYER UP, where a dispatch is retried as a whole (fresh
|
|
25
|
+
# hydrate, fresh `given`s), not as a lone SQL statement.
|
|
26
|
+
module PostgresReconnect
|
|
27
|
+
def pg_exec(sql)
|
|
28
|
+
@db.exec(sql)
|
|
29
|
+
rescue PG::ConnectionBad
|
|
30
|
+
reconnect!
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pg_exec_params(sql, binds)
|
|
35
|
+
@db.exec_params(sql, binds)
|
|
36
|
+
rescue PG::ConnectionBad
|
|
37
|
+
reconnect!
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# BEST-EFFORT — a reconnect attempt that itself fails (the server
|
|
44
|
+
# is actually down, not just this one backend) leaves `@db`
|
|
45
|
+
# unchanged; the `PG::ConnectionBad` already being re-raised by
|
|
46
|
+
# `pg_exec`/`pg_exec_params` above still reaches the caller either
|
|
47
|
+
# way, so swallowing a failed RECONNECT attempt here loses no
|
|
48
|
+
# information — it only avoids masking the original error with a
|
|
49
|
+
# second one.
|
|
50
|
+
def reconnect!
|
|
51
|
+
@db = self.class.connect_for(@aggregate.name, @settings)
|
|
52
|
+
rescue PG::Error
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -28,6 +28,25 @@ module Hecks
|
|
|
28
28
|
# `persisted_fields` (Codec), so it never appears in `decode`'s
|
|
29
29
|
# domain-state hash or `Instance#to_h`.
|
|
30
30
|
@db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS hecks_version bigint NOT NULL DEFAULT 1")
|
|
31
|
+
# SAME SELF-HEALING SHAPE, FOR DOMAIN ATTRIBUTES THEMSELVES —
|
|
32
|
+
# `hecks_version` above only heals the adapter's own bookkeeping
|
|
33
|
+
# column; a bluebook attribute added (or, via `translations/`,
|
|
34
|
+
# renamed) after this table already exists is not bookkeeping,
|
|
35
|
+
# but the identical gap applies: `CREATE TABLE IF NOT EXISTS`
|
|
36
|
+
# is a no-op against the existing table, so without this a new
|
|
37
|
+
# attribute boots clean and then dies `PG::UndefinedColumn` on
|
|
38
|
+
# the first `project` — discovered chaos-testing a live rename
|
|
39
|
+
# against this adapter (no era, no translation prompt; those
|
|
40
|
+
# live in `PostgresEra` — this is the plain adapter's own,
|
|
41
|
+
# simpler contract: "the table always has every column the
|
|
42
|
+
# bluebook currently declares"). No `NOT NULL`, no `DEFAULT` —
|
|
43
|
+
# existing rows get SQL NULL for a column they never had a
|
|
44
|
+
# value for, exactly what a fresh row would get for an unset
|
|
45
|
+
# optional attribute (`encode_field`'s own nil handling).
|
|
46
|
+
persisted_fields.each do |field|
|
|
47
|
+
@db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS " \
|
|
48
|
+
"#{quote_ident(field[:name])} #{field[:sql_type]}")
|
|
49
|
+
end
|
|
31
50
|
# RIGHT HERE, NOT A SEPARATE STEP IN `Postgres#initialize` —
|
|
32
51
|
# same idiom Sqlite::SchemaBuilder's own `create_aggregate_table!`
|
|
33
52
|
# uses: index creation runs unconditionally, right after the
|
|
@@ -10,6 +10,7 @@ require_relative "../../query_specification/field_path"
|
|
|
10
10
|
require_relative "../../runtime/errors"
|
|
11
11
|
require_relative "../../runtime/event"
|
|
12
12
|
require_relative "postgres/outbox"
|
|
13
|
+
require_relative "postgres/reconnect"
|
|
13
14
|
require_relative "../../runtime/instance"
|
|
14
15
|
|
|
15
16
|
module Hecks
|
|
@@ -46,6 +47,7 @@ module Hecks
|
|
|
46
47
|
include SchemaBuilder
|
|
47
48
|
include Codec
|
|
48
49
|
include PostgresOutbox
|
|
50
|
+
include PostgresReconnect
|
|
49
51
|
|
|
50
52
|
SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
|
|
51
53
|
|
|
@@ -93,6 +95,7 @@ module Hecks
|
|
|
93
95
|
|
|
94
96
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
95
97
|
@aggregate = aggregate
|
|
98
|
+
@settings = settings
|
|
96
99
|
@db = self.class.connect_for(aggregate.name, settings)
|
|
97
100
|
# THE OPTIONAL saga-persistence capability's own scoping column
|
|
98
101
|
# (§2/§4) — falls back to the aggregate's own storage name for a
|
|
@@ -118,7 +121,7 @@ module Hecks
|
|
|
118
121
|
def table = @aggregate.storage_name
|
|
119
122
|
|
|
120
123
|
def find(id)
|
|
121
|
-
result =
|
|
124
|
+
result = pg_exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
|
|
122
125
|
return nil if result.ntuples.zero?
|
|
123
126
|
|
|
124
127
|
instance_from_row(result[0])
|
|
@@ -140,13 +143,13 @@ module Hecks
|
|
|
140
143
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
141
144
|
end
|
|
142
145
|
|
|
143
|
-
|
|
146
|
+
pg_exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
|
|
144
147
|
end
|
|
145
148
|
|
|
146
|
-
def count =
|
|
149
|
+
def count = pg_exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
|
|
147
150
|
|
|
148
151
|
def append(entry)
|
|
149
|
-
|
|
152
|
+
pg_exec_params(
|
|
150
153
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES ($1, $2, $3, $4)",
|
|
151
154
|
# `mirrors` (unlike `state`) is a NULLABLE column — an absent
|
|
152
155
|
# mirrors hash must bind a real SQL NULL, not the four-character
|
|
@@ -178,7 +181,7 @@ module Hecks
|
|
|
178
181
|
# rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
|
|
179
182
|
# protocol; splitting it would hide the version handshake.
|
|
180
183
|
def project(entry, expected_version: nil)
|
|
181
|
-
return
|
|
184
|
+
return pg_exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
|
|
182
185
|
|
|
183
186
|
instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
184
187
|
columns = (["id"] + persisted_fields.map { |field| field[:name].to_s } + ["hecks_version"])
|
|
@@ -195,7 +198,7 @@ module Hecks
|
|
|
195
198
|
end
|
|
196
199
|
sql += " RETURNING hecks_version"
|
|
197
200
|
|
|
198
|
-
result =
|
|
201
|
+
result = pg_exec_params(sql, values)
|
|
199
202
|
return nil if result.ntuples.zero?
|
|
200
203
|
|
|
201
204
|
instance.version = result[0]["hecks_version"].to_i
|
|
@@ -204,7 +207,7 @@ module Hecks
|
|
|
204
207
|
# rubocop:enable Metrics/AbcSize
|
|
205
208
|
|
|
206
209
|
def entries
|
|
207
|
-
|
|
210
|
+
pg_exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
|
|
208
211
|
state = JSON.parse(row["state"])
|
|
209
212
|
Ports::Persistence::Entry.new(
|
|
210
213
|
operation: row["operation"] || "save",
|
|
@@ -216,8 +219,8 @@ module Hecks
|
|
|
216
219
|
end
|
|
217
220
|
|
|
218
221
|
def reset!
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
pg_exec("DELETE FROM #{quoted_table}")
|
|
223
|
+
pg_exec("DELETE FROM #{quoted_entry_table}")
|
|
221
224
|
self
|
|
222
225
|
end
|
|
223
226
|
|
|
@@ -239,12 +242,12 @@ module Hecks
|
|
|
239
242
|
def atomic_put(entry, insert_only: false)
|
|
240
243
|
status = nil
|
|
241
244
|
transaction do
|
|
242
|
-
|
|
245
|
+
pg_exec_params(
|
|
243
246
|
"SELECT pg_advisory_xact_lock(" \
|
|
244
247
|
"hashtext(current_schema() || ':' || $1), hashtext($2))",
|
|
245
248
|
[table, entry.id.to_s]
|
|
246
249
|
)
|
|
247
|
-
exists =
|
|
250
|
+
exists = !pg_exec_params(
|
|
248
251
|
"SELECT 1 FROM #{quoted_table} WHERE id = $1",
|
|
249
252
|
[entry.id.to_s]
|
|
250
253
|
).ntuples.zero?
|
|
@@ -269,14 +272,14 @@ module Hecks
|
|
|
269
272
|
end
|
|
270
273
|
|
|
271
274
|
def record_event(event)
|
|
272
|
-
|
|
275
|
+
pg_exec_params(
|
|
273
276
|
"INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
|
|
274
277
|
[event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
|
|
275
278
|
)
|
|
276
279
|
end
|
|
277
280
|
|
|
278
281
|
def events
|
|
279
|
-
|
|
282
|
+
pg_exec("SELECT * FROM events ORDER BY id").map do |row|
|
|
280
283
|
Runtime::Event.new(
|
|
281
284
|
name: row["name"],
|
|
282
285
|
aggregate: row["aggregate"],
|
|
@@ -291,7 +294,7 @@ module Hecks
|
|
|
291
294
|
# shape as PostgresEra's own (postgres_era.rb), not lineage-
|
|
292
295
|
# specific, copied verbatim.
|
|
293
296
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
294
|
-
|
|
297
|
+
pg_exec_params(
|
|
295
298
|
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
296
299
|
"VALUES ($1, $2, $3, $4, $5, $6) " \
|
|
297
300
|
"ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
|
|
@@ -303,7 +306,7 @@ module Hecks
|
|
|
303
306
|
end
|
|
304
307
|
|
|
305
308
|
def delete_saga(process_manager:, correlation:)
|
|
306
|
-
|
|
309
|
+
pg_exec_params(
|
|
307
310
|
"DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
|
|
308
311
|
[@domain, process_manager.to_s, correlation.to_s]
|
|
309
312
|
)
|
|
@@ -312,7 +315,7 @@ module Hecks
|
|
|
312
315
|
def each_saga
|
|
313
316
|
return enum_for(:each_saga) unless block_given?
|
|
314
317
|
|
|
315
|
-
|
|
318
|
+
pg_exec_params(
|
|
316
319
|
"SELECT process_manager, correlation, state, memory, completed_compensations " \
|
|
317
320
|
"FROM hecks_saga_instances WHERE domain = $1",
|
|
318
321
|
[@domain]
|
|
@@ -381,7 +384,7 @@ module Hecks
|
|
|
381
384
|
end
|
|
382
385
|
|
|
383
386
|
def execute_query(sql, binds)
|
|
384
|
-
|
|
387
|
+
pg_exec_params(sql, binds).map { |row| instance_from_row(row) }
|
|
385
388
|
end
|
|
386
389
|
|
|
387
390
|
# Stamps `.version` (adapter bookkeeping, never domain state — see
|
|
@@ -67,7 +67,10 @@ module Hecks
|
|
|
67
67
|
# reads it — not the identity unwrap, which is gone : an identity is
|
|
68
68
|
# declared as a path and followed.
|
|
69
69
|
reference_id = args.fetch(model.reference_name).to_s
|
|
70
|
-
|
|
70
|
+
# Plural (ADR 0055) — `on:` lets `where`/`order_by`/`limit`/`offset`
|
|
71
|
+
# each name a different many-side head, so more than one can be
|
|
72
|
+
# eligible in the same read model now.
|
|
73
|
+
eligible = model.filtered_head_names
|
|
71
74
|
|
|
72
75
|
# ROOT FIRST, ALWAYS — see this method's own header. Mirrors
|
|
73
76
|
# `ReadModelInterpreter#project`'s identical partition, for the
|
|
@@ -87,7 +90,7 @@ module Hecks
|
|
|
87
90
|
else
|
|
88
91
|
select_related(aggregate, projected)
|
|
89
92
|
end
|
|
90
|
-
rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as]
|
|
93
|
+
rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
|
|
91
94
|
projected << { aggregate: head[:aggregate], rows: rows }
|
|
92
95
|
reports[head[:as]] = if head[:many]
|
|
93
96
|
rows.map { |row| Runtime::Value.materialize(row.to_h) }
|
|
@@ -170,9 +170,19 @@ module Hecks
|
|
|
170
170
|
# a saga with nothing to do.
|
|
171
171
|
def read(value) = Literal.read(value)
|
|
172
172
|
|
|
173
|
+
# `target:` (ADR 0055) — read straight off the wire, unconverted:
|
|
174
|
+
# it's already the bare aggregate-name STRING `WhereClause#to_h`/
|
|
175
|
+
# `OrderBy#to_h`/`LimitSpec#to_h` wrote (`resolve_target`'s own
|
|
176
|
+
# `Naming.demodulise` already ran once, at DSL-build time; this is
|
|
177
|
+
# the REPLAY path every real boot actually goes through, reading
|
|
178
|
+
# that same wire shape back — see this class's own header). Absent
|
|
179
|
+
# from `clause`/`declared` entirely on older wire data that never
|
|
180
|
+
# declared `on:` — `clause[:target]`/`declared[:target]` reads
|
|
181
|
+
# `nil` for a missing key exactly like an explicit `nil` would,
|
|
182
|
+
# so this is additive, not a migration.
|
|
173
183
|
def where_clause(clause)
|
|
174
184
|
QuerySpecification::Common::WhereClause.new(
|
|
175
|
-
field: clause[:field], op: clause[:op].to_sym, value: read(clause[:value])
|
|
185
|
+
field: clause[:field], op: clause[:op].to_sym, value: read(clause[:value]), target: clause[:target]
|
|
176
186
|
)
|
|
177
187
|
end
|
|
178
188
|
|
|
@@ -180,14 +190,14 @@ module Hecks
|
|
|
180
190
|
return nil unless declared
|
|
181
191
|
|
|
182
192
|
QuerySpecification::Common::OrderBy.new(
|
|
183
|
-
field: declared[:field], direction: declared[:direction].to_sym
|
|
193
|
+
field: declared[:field], direction: declared[:direction].to_sym, target: declared[:target]
|
|
184
194
|
)
|
|
185
195
|
end
|
|
186
196
|
|
|
187
197
|
def limit(declared)
|
|
188
198
|
return nil unless declared
|
|
189
199
|
|
|
190
|
-
QuerySpecification::Common::LimitSpec.new(value: read(declared[:value]))
|
|
200
|
+
QuerySpecification::Common::LimitSpec.new(value: read(declared[:value]), target: declared[:target])
|
|
191
201
|
end
|
|
192
202
|
|
|
193
203
|
# EVERY OTHER SPECIFICATION OPTION, from one table.
|
|
@@ -14,14 +14,59 @@ module Hecks
|
|
|
14
14
|
|
|
15
15
|
def query_name = Naming.snake(@name)
|
|
16
16
|
|
|
17
|
-
# WHICH GATHERED
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
# WHICH GATHERED HEADS THE FILTERING APPLIES TO (ADR 0055) — plural,
|
|
18
|
+
# since `where`/`order_by`/`limit`/`offset` can now each independently
|
|
19
|
+
# name a many-side head via `on:` once there's more than one. A read
|
|
20
|
+
# model with a single many-side head keeps the old reading: every
|
|
21
|
+
# UNTARGETED option (plus `group_by`/`count`/`median`, still
|
|
22
|
+
# single-head-only — ADR 0055) applies to it, same as before `on:`
|
|
23
|
+
# existed. With several many-side heads, only the ones actually named
|
|
24
|
+
# by a targeted option are eligible.
|
|
25
|
+
def filtered_head_names
|
|
26
|
+
many = @aggregate_heads.select { |head| head[:many] }
|
|
27
|
+
return [] if many.empty?
|
|
28
|
+
|
|
29
|
+
return single_filtered_head_name(many) if many.one?
|
|
30
|
+
|
|
31
|
+
targets = (wheres.map(&:target) + [order_by&.target, limit&.target, offset&.target]).compact.uniq
|
|
32
|
+
targets.filter_map { |target| many.find { |head| head[:aggregate] == target.to_s } }.map { |head| head[:as] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The pre-`on:` reading (ADR 0055), unchanged: with exactly one
|
|
36
|
+
# many-side head, every UNTARGETED option (plus `group_by`/`count`/
|
|
37
|
+
# `median`, still single-head-only) applies to it — split out only
|
|
38
|
+
# to keep `filtered_head_names` itself under this file's own
|
|
39
|
+
# complexity budget, not because the two questions differ in kind.
|
|
40
|
+
def single_filtered_head_name(many)
|
|
41
|
+
declared = wheres.any? || order_by || limit || offset || authorization&.tenant ||
|
|
42
|
+
@group_by.any? || count? || @median_field
|
|
43
|
+
declared ? [many.first[:as]] : []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# THE where/order_by/limit/offset THAT APPLY TO ONE ELIGIBLE HEAD
|
|
47
|
+
# (ADR 0055) — a small view `Ports::Query::InMemory.execute` reads
|
|
48
|
+
# exactly the way it already reads a whole `Query`/`ReadModel`
|
|
49
|
+
# (`.wheres`/`.order_by`/`.limit`/`.offset`/`.null_semantics`), scoped
|
|
50
|
+
# to `head_as`'s own aggregate: an UNTARGETED option applies when
|
|
51
|
+
# `head_as` is the read model's ONE many-side head (the pre-`on:`
|
|
52
|
+
# reading, unchanged) ; a TARGETED one applies when its `target`
|
|
53
|
+
# resolves to `head_as`'s own aggregate.
|
|
54
|
+
FilteredOptions = Struct.new(:wheres, :order_by, :limit, :offset, :null_semantics)
|
|
55
|
+
|
|
56
|
+
def options_for(head_as)
|
|
57
|
+
many = @aggregate_heads.select { |head| head[:many] }
|
|
58
|
+
aggregate_name = @aggregate_heads.find { |head| head[:as] == head_as }&.fetch(:aggregate)
|
|
59
|
+
applies = lambda do |target|
|
|
60
|
+
target.nil? ? many.one? : target.to_s == aggregate_name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
FilteredOptions.new(
|
|
64
|
+
wheres.select { |where| applies.call(where.target) },
|
|
65
|
+
order_by && applies.call(order_by.target) ? order_by : nil,
|
|
66
|
+
limit && applies.call(limit.target) ? limit : nil,
|
|
67
|
+
offset && applies.call(offset.target) ? offset : nil,
|
|
68
|
+
null_semantics
|
|
69
|
+
)
|
|
25
70
|
end
|
|
26
71
|
end
|
|
27
72
|
end
|
|
@@ -88,13 +88,18 @@ module Hecks
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# `optional:` — matching `CommandBuilder#reference_to`'s own
|
|
91
|
-
# signature, which already had it; this one
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
91
|
+
# signature, which already had it; this one used to never
|
|
92
|
+
# forward it to `attribute_impl()`/`relationship_attribute`
|
|
93
|
+
# even though those already accept it — closed in the same
|
|
94
|
+
# commit that added this comment (`optional: optional`, below).
|
|
95
|
+
# It was a real gap because an aggregate that can point at ONE
|
|
96
|
+
# OF several targets (Item's own `personal_list_id`/
|
|
95
97
|
# `camping_list_id`, never both) needs each reference optional
|
|
96
98
|
# on the aggregate's own persisted schema, not just as a
|
|
97
|
-
# command's input
|
|
99
|
+
# command's input — real corpus use:
|
|
100
|
+
# `spec/fixtures/hop_chain.bluebook`'s own `Proposal` aggregate
|
|
101
|
+
# declares `reference_to Engagement, optional: true` at the
|
|
102
|
+
# aggregate head.
|
|
98
103
|
# RENAMED FROM `reference_to` — item #13's full metaprogrammed
|
|
99
104
|
# dispatch (slice 4b). Bootstrap-reachable (every core/attached
|
|
100
105
|
# grammar chapter uses reference_to to describe itself), so also
|
|
@@ -54,6 +54,72 @@ module Hecks
|
|
|
54
54
|
@includes << [Naming.demodulise(type), as]
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# `on:` (ADR 0055) — OVERRIDES of `QuerySpecification::Common::DSL`'s
|
|
58
|
+
# shared `where_impl`/`order_by_impl`/`limit_impl`/`offset_impl`,
|
|
59
|
+
# scoped to `ReadModelBuilder` alone rather than added to the shared
|
|
60
|
+
# module `Query` also mixes in: a plain `query` has no
|
|
61
|
+
# `aggregate_heads` at all, so `on:` there would be a silently-
|
|
62
|
+
# ignored no-op rather than a real answer. Overriding only here
|
|
63
|
+
# means a `Query`'s own `where(..., on: X)` gets Ruby's own loud
|
|
64
|
+
# `unknown keyword: :on` instead of quietly doing nothing.
|
|
65
|
+
#
|
|
66
|
+
# `on:` names the target by TYPE (`on: Character`), resolved the
|
|
67
|
+
# same way `reference_to`/`include` already resolve their own type
|
|
68
|
+
# argument (`Naming.demodulise`) — not by the include's own `as:`
|
|
69
|
+
# alias. A read model that `include`s the SAME type twice under two
|
|
70
|
+
# different `as:` has no way to say which one `on:` means today; no
|
|
71
|
+
# real corpus read model does this, so it's a real, deliberate scope
|
|
72
|
+
# limit (see ADR 0055), not an oversight.
|
|
73
|
+
#
|
|
74
|
+
# `*positional, on:, **rest` rather than a plain `(clauses, on: nil)`
|
|
75
|
+
# — found necessary, not stylistic, by reproducing the failure
|
|
76
|
+
# directly: `where(status: "disputed")` reaches here with its
|
|
77
|
+
# `status: "disputed"` captured as `**kwargs` (GenericDispatch's own
|
|
78
|
+
# `builder.send(calls, *args, **kwargs, &block)`), and Ruby stops
|
|
79
|
+
# auto-converting a bare `**hash` call into a plain positional Hash
|
|
80
|
+
# THE MOMENT a method declares any real keyword parameter — so a
|
|
81
|
+
# `(clauses, on: nil)` signature raised "wrong number of arguments
|
|
82
|
+
# (given 0, expected 1)" on every ordinary `where(field: value)`
|
|
83
|
+
# call, never reaching `on:` at all. `**rest` sidesteps this: Ruby
|
|
84
|
+
# still auto-splits `on:` into the declared keyword and gathers
|
|
85
|
+
# every OTHER key into `rest` regardless of how the caller wrote it.
|
|
86
|
+
#
|
|
87
|
+
# `QuerySpecification::Common::WhereClause` etc — FULLY QUALIFIED,
|
|
88
|
+
# not the bare names `dsl.rb`'s own shared `where_impl` gets away
|
|
89
|
+
# with. That file is lexically nested inside `Common` itself, so
|
|
90
|
+
# `WhereClause` resolves directly; this class is nested inside
|
|
91
|
+
# `Bluebook::DSL`, which has no lexical or ancestor path to
|
|
92
|
+
# `QuerySpecification::Common` at all — a bare `WhereClause` here
|
|
93
|
+
# falls through to `const_missing` and, mid-bluebook-load, that's
|
|
94
|
+
# `ConstShim`, which resolves it against the self-hosted grammar
|
|
95
|
+
# domain's OWN unrelated `WhereClause` construct instead (a `Module`,
|
|
96
|
+
# not this `Struct`) — found directly by reproducing "undefined
|
|
97
|
+
# method `new' for module WhereClause" against a real corpus load,
|
|
98
|
+
# not guessed.
|
|
99
|
+
def where_impl(*positional, on: nil, **rest)
|
|
100
|
+
raise ArgumentError, "wrong number of arguments (given #{positional.size}, expected 1)" if positional.size > 1
|
|
101
|
+
|
|
102
|
+
@wheres ||= []
|
|
103
|
+
target = resolve_target(on)
|
|
104
|
+
clauses = (positional.first || {}).merge(rest)
|
|
105
|
+
clauses.each do |field, value|
|
|
106
|
+
op, operand = split_comparator(value)
|
|
107
|
+
@wheres << QuerySpecification::Common::WhereClause.new(field: field, op: op, value: operand, target: target)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def order_by_impl(field, direction = :asc, on: nil)
|
|
112
|
+
@order_by = QuerySpecification::Common::OrderBy.new(field: field, direction: direction, target: resolve_target(on))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def limit_impl(value, on: nil)
|
|
116
|
+
@limit = QuerySpecification::Common::LimitSpec.new(value: value, target: resolve_target(on))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def offset_impl(value, on: nil)
|
|
120
|
+
@offset = QuerySpecification::Common::OffsetSpec.new(value: value, target: resolve_target(on))
|
|
121
|
+
end
|
|
122
|
+
|
|
57
123
|
# NAMES which of the eligible head's own fields to nest its rows
|
|
58
124
|
# under — one level per field, the leaf being that row with the
|
|
59
125
|
# named fields removed (they're already spent, as the keys that
|
|
@@ -142,27 +208,70 @@ module Hecks
|
|
|
142
208
|
|
|
143
209
|
private
|
|
144
210
|
|
|
145
|
-
# where/order_by/limit/offset/authorize's tenant all apply to
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
211
|
+
# where/order_by/limit/offset/authorize's tenant all apply to
|
|
212
|
+
# collections — the `include`d aggregates whose heads are "many"
|
|
213
|
+
# (the "one" side, the reference target itself, is a single row;
|
|
214
|
+
# ordering, paging, or tenant-scoping one row means nothing). ADR
|
|
215
|
+
# 0055 gave `where`/`order_by`/`limit`/`offset` an `on:` to name
|
|
216
|
+
# WHICH many-side collection they mean, so this asks two questions
|
|
217
|
+
# now instead of one:
|
|
218
|
+
#
|
|
219
|
+
# 1. Does every declared `on:` actually name a many-side included
|
|
220
|
+
# aggregate? Checked regardless of how many many-side heads
|
|
221
|
+
# exist — a typo refuses immediately, not only once ambiguity
|
|
222
|
+
# would otherwise bite.
|
|
223
|
+
# 2. Is there still an UNTARGETED option declared (including
|
|
224
|
+
# `authorize`'s own `tenant:`, which has no `on:` of its own —
|
|
225
|
+
# a real, deliberate scope limit, see ADR 0055)? An untargeted
|
|
226
|
+
# option still needs exactly one many-side head to mean
|
|
227
|
+
# anything unambiguous — the ORIGINAL rule, unchanged, and
|
|
228
|
+
# still worded the same way (`spec/runtime/
|
|
229
|
+
# read_model_interpreter_spec.rb`'s existing refusal regex
|
|
230
|
+
# still matches).
|
|
231
|
+
#
|
|
232
|
+
# A read model with several many-side heads is legal precisely when
|
|
233
|
+
# every declared option names one; a read model with a single
|
|
234
|
+
# many-side head is unaffected either way, `on:` or not.
|
|
155
235
|
def seal_query_options
|
|
156
|
-
|
|
157
|
-
return unless declared
|
|
236
|
+
many = Array(@aggregate_heads).select { |head| head[:many] }
|
|
158
237
|
|
|
159
|
-
many
|
|
160
|
-
return
|
|
238
|
+
validate_declared_targets!(many)
|
|
239
|
+
return unless untargeted_option_declared?
|
|
240
|
+
return if many.size == 1
|
|
161
241
|
|
|
162
242
|
raise Malformed,
|
|
163
|
-
"#{@name} declares where/order_by/limit/offset but includes #{many} many-side " \
|
|
243
|
+
"#{@name} declares where/order_by/limit/offset but includes #{many.size} many-side " \
|
|
164
244
|
"aggregates, not exactly one — these options apply to a single collection; " \
|
|
165
|
-
"name which one
|
|
245
|
+
"name which one with `on:` (e.g. `where(field: value, on: Character)`), or drop the options"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Question 1 of `seal_query_options`'s own two, split out to keep
|
|
249
|
+
# both under the same "one job per method" shape every OTHER seal in
|
|
250
|
+
# this file already holds to (each raises its own one Malformed, for
|
|
251
|
+
# its own one reason).
|
|
252
|
+
def validate_declared_targets!(many)
|
|
253
|
+
many_by_aggregate = many.to_h { |head| [head[:aggregate], head] }
|
|
254
|
+
declared_targets = Array(@wheres).map(&:target) + [@order_by&.target, @limit&.target, @offset&.target]
|
|
255
|
+
|
|
256
|
+
declared_targets.compact.uniq.each do |target|
|
|
257
|
+
next if many_by_aggregate.key?(target)
|
|
258
|
+
|
|
259
|
+
raise Malformed,
|
|
260
|
+
"#{@name}'s `on: #{target}` doesn't name one of its own many-side included " \
|
|
261
|
+
"aggregates (it includes #{many.map { |head| head[:aggregate] }.join(', ')} as " \
|
|
262
|
+
"many-side heads)"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Question 2 of `seal_query_options`'s own two — see that method's
|
|
267
|
+
# header. `authorize`'s own `tenant:` has no `on:` at all (ADR 0055's
|
|
268
|
+
# own documented scope limit), so it always counts as untargeted.
|
|
269
|
+
def untargeted_option_declared?
|
|
270
|
+
Array(@wheres).any? { |where| where.target.nil? } ||
|
|
271
|
+
(@order_by && @order_by.target.nil?) ||
|
|
272
|
+
(@limit && @limit.target.nil?) ||
|
|
273
|
+
(@offset && @offset.target.nil?) ||
|
|
274
|
+
@authorization&.tenant
|
|
166
275
|
end
|
|
167
276
|
|
|
168
277
|
# Same shape as `seal_query_options`, same reason — `group_by`
|
|
@@ -227,6 +336,12 @@ module Hecks
|
|
|
227
336
|
"pagination — use limit/offset instead"
|
|
228
337
|
end
|
|
229
338
|
|
|
339
|
+
# `on:`'s own resolution (ADR 0055) — same demodulise `reference_to`/
|
|
340
|
+
# `include` already use for their own type argument. `nil` when `on:`
|
|
341
|
+
# is omitted, matching every other optional field's "absent, not
|
|
342
|
+
# false" reading in this file.
|
|
343
|
+
def resolve_target(on) = on && Naming.demodulise(on)
|
|
344
|
+
|
|
230
345
|
def add_aggregate_head(type, name, many:)
|
|
231
346
|
@aggregate_heads ||= []
|
|
232
347
|
target = Naming.demodulise(type)
|
|
@@ -147,6 +147,17 @@ module Hecks
|
|
|
147
147
|
yield
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# ONLY an adapter advertising `:cross_process_lock` (PostgresEra —
|
|
151
|
+
# see ADR 0036) implements this; `run_dispatch_order_with_isolation`
|
|
152
|
+
# (runtime/interpreting.rb) checks `capabilities` before ever
|
|
153
|
+
# calling it, so the plain `yield` fallback here only guards
|
|
154
|
+
# against a stray direct call, not the real dispatch path.
|
|
155
|
+
def with_write_lock(&)
|
|
156
|
+
return @adapter.with_write_lock(&) if @adapter.respond_to?(:with_write_lock)
|
|
157
|
+
|
|
158
|
+
yield
|
|
159
|
+
end
|
|
160
|
+
|
|
150
161
|
# THE OUTBOX CONTRACT — four optional adapter methods, probed
|
|
151
162
|
# together the way `save_saga`/`delete_saga`/`each_saga` are
|
|
152
163
|
# (`Registry::SagaPersistence`): an adapter either has an outbox
|