sequel-notion 0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +207 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +470 -0
  5. data/lib/sequel/adapters/notion.rb +159 -0
  6. data/lib/sequel/notion/dataset.rb +167 -0
  7. data/lib/sequel/notion/dataset_aggregates.rb +82 -0
  8. data/lib/sequel/notion/dataset_compounds.rb +89 -0
  9. data/lib/sequel/notion/dataset_computed.rb +124 -0
  10. data/lib/sequel/notion/dataset_grouping.rb +145 -0
  11. data/lib/sequel/notion/dataset_having.rb +99 -0
  12. data/lib/sequel/notion/dataset_joins.rb +124 -0
  13. data/lib/sequel/notion/dataset_pages.rb +145 -0
  14. data/lib/sequel/notion/dataset_selection.rb +51 -0
  15. data/lib/sequel/notion/dataset_truncation.rb +39 -0
  16. data/lib/sequel/notion/discovery.rb +76 -0
  17. data/lib/sequel/notion/errors.rb +9 -0
  18. data/lib/sequel/notion/filter_comparison.rb +123 -0
  19. data/lib/sequel/notion/filter_compiler.rb +121 -0
  20. data/lib/sequel/notion/filter_constants.rb +37 -0
  21. data/lib/sequel/notion/filter_helpers.rb +162 -0
  22. data/lib/sequel/notion/filter_like.rb +143 -0
  23. data/lib/sequel/notion/filter_like_tokenizer.rb +42 -0
  24. data/lib/sequel/notion/filter_negation.rb +76 -0
  25. data/lib/sequel/notion/filter_nested.rb +96 -0
  26. data/lib/sequel/notion/filter_nulls.rb +53 -0
  27. data/lib/sequel/notion/filter_predicates.rb +160 -0
  28. data/lib/sequel/notion/filter_shape.rb +75 -0
  29. data/lib/sequel/notion/filter_tables.rb +111 -0
  30. data/lib/sequel/notion/group_accumulator.rb +55 -0
  31. data/lib/sequel/notion/join_output.rb +58 -0
  32. data/lib/sequel/notion/join_where.rb +111 -0
  33. data/lib/sequel/notion/model_support.rb +52 -0
  34. data/lib/sequel/notion/notion_file.rb +228 -0
  35. data/lib/sequel/notion/page_api.rb +49 -0
  36. data/lib/sequel/notion/registry.rb +150 -0
  37. data/lib/sequel/notion/request_budget.rb +64 -0
  38. data/lib/sequel/notion/schema.rb +74 -0
  39. data/lib/sequel/notion/schema_lookup.rb +82 -0
  40. data/lib/sequel/notion/sort_compiler.rb +63 -0
  41. data/lib/sequel/notion/type_map.rb +125 -0
  42. data/lib/sequel/notion/type_map_builders.rb +125 -0
  43. data/lib/sequel/notion/type_map_dates.rb +67 -0
  44. data/lib/sequel/notion/type_map_extractors.rb +89 -0
  45. data/lib/sequel/notion/version.rb +7 -0
  46. data/lib/sequel/notion.rb +5 -0
  47. metadata +132 -0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/dataset_aggregates"
4
+ require "sequel/notion/dataset_compounds"
5
+ require "sequel/notion/dataset_computed"
6
+ require "sequel/notion/dataset_grouping"
7
+ require "sequel/notion/dataset_joins"
8
+ require "sequel/notion/dataset_pages"
9
+ require "sequel/notion/dataset_selection"
10
+ require "sequel/notion/dataset_truncation"
11
+ require "sequel/notion/type_map"
12
+
13
+ module Sequel
14
+ module Notion
15
+ class Dataset < Sequel::Dataset
16
+ include DatasetAggregates
17
+ include DatasetCompounds
18
+ include DatasetComputed
19
+ include DatasetGrouping
20
+ include DatasetJoins
21
+ include DatasetPages
22
+ include DatasetSelection
23
+ include DatasetTruncation
24
+
25
+ def columns
26
+ computed_columns || selection&.map(&:last) ||
27
+ db.schema(source_table).map(&:first)
28
+ end
29
+
30
+ def columns! = columns
31
+
32
+ # Sequel renders SQL before fetching; there is none to render
33
+ def select_sql = "NOTION #{compound? ? "compound" : source_table}"
34
+
35
+ # Computed in Ruby, the first row of each key
36
+ def supports_distinct_on? = true
37
+
38
+ # Sequel's cached loaders swap WHERE for SQL placeholders,
39
+ # which only a SQL database can fill in
40
+ def supports_placeholder_literalizer? = false
41
+
42
+ # Rows, auto-paginated, honouring LIMIT, OFFSET and SELECT
43
+ def fetch_rows(sql)
44
+ if @opts[:sql] || sql != select_sql
45
+ raise Error, "Notion datasets take no SQL"
46
+ end
47
+
48
+ budgeted { each_row { yield it } }
49
+ end
50
+
51
+ # Streams through Notion's cursor, as cursor adapters do: no
52
+ # order needed, one request per page of rows_per_fetch (at
53
+ # most 100). Sequel's :strategy, which pages by OFFSET or by
54
+ # filtering on the order columns, does not apply.
55
+ def paged_each(opts = OPTS, &)
56
+ return enum_for(:paged_each, opts) unless block_given?
57
+
58
+ size = opts[:rows_per_fetch]
59
+ (size ? clone(notion_page_size: size) : self).each(&)
60
+ self
61
+ end
62
+
63
+ def empty? = limit(1).first.nil?
64
+
65
+ # ----------------------------------------------------------
66
+ # Insert → create page
67
+ # ----------------------------------------------------------
68
+
69
+ def insert(*values)
70
+ row = insert_hash(values).transform_keys(&:to_sym)
71
+ row.delete(:id)
72
+ if row.delete(:in_trash)
73
+ raise Error, "a page cannot be created in the trash"
74
+ end
75
+
76
+ db.notion_create_page(data_source_id, properties_for(row))["id"]
77
+ end
78
+
79
+ def import(columns, values, _opts = OPTS)
80
+ values.map { insert(columns.zip(it).to_h) }
81
+ end
82
+
83
+ def multi_insert(hashes, _opts = OPTS) = hashes.map { insert(it) }
84
+
85
+ # ----------------------------------------------------------
86
+ # Update / delete — ids are collected first, so that pages
87
+ # leaving the filter while being patched are not skipped
88
+ # ----------------------------------------------------------
89
+
90
+ def update(values = OPTS)
91
+ raise Error, "update takes a Hash" unless values.is_a?(Hash)
92
+
93
+ values = values.transform_keys(&:to_sym)
94
+ in_trash = values.delete(:in_trash)
95
+ values.delete(:id)
96
+ props = properties_for(values)
97
+
98
+ page_ids.each do |id|
99
+ db.notion_update_page(id, props, in_trash:)
100
+ end.size
101
+ end
102
+
103
+ def delete
104
+ page_ids.each { db.notion_trash_page(it) }.size
105
+ end
106
+
107
+ private
108
+
109
+ def page_rows
110
+ sel = selection
111
+ each_notion_page do |page|
112
+ yield project(TypeMap.page_to_row(complete_page(page, sel)),
113
+ sel)
114
+ end
115
+ end
116
+
117
+ # One query's requests, under client_side's max_requests
118
+ def budgeted(&) = db.with_request_budget(@opts[:max_requests], &)
119
+
120
+ def source_table
121
+ from = @opts[:from]
122
+ unless from&.size == 1 && from.first.is_a?(Symbol)
123
+ raise Error, "Notion datasets need a single table name"
124
+ end
125
+
126
+ from.first
127
+ end
128
+
129
+ def data_source_id
130
+ db.data_source_id_for(source_table) or
131
+ raise Error, "Unknown data source: #{source_table}"
132
+ end
133
+
134
+ def properties_for(values)
135
+ types = db.property_type_map(data_source_id)
136
+ TypeMap.row_to_properties(values, types)
137
+ end
138
+
139
+ # Columns a positional insert fills, in schema order
140
+ def writable
141
+ db.schema(source_table)
142
+ .reject { |_, info| info[:generated] }
143
+ .map(&:first) - Schema::PAGE_COLUMNS.map(&:first)
144
+ end
145
+
146
+ def positional(vals)
147
+ cols = writable
148
+ if vals.size > cols.size
149
+ raise Error, "#{vals.size} values for #{cols.size} " \
150
+ "writable columns"
151
+ end
152
+
153
+ cols.take(vals.size).zip(vals).to_h
154
+ end
155
+
156
+ def insert_hash(values)
157
+ case values
158
+ in [] then {}
159
+ in [Hash => hash] then hash
160
+ in [Array => cols, Array => vals] then cols.zip(vals).to_h
161
+ in [Array => vals] then positional(vals)
162
+ else raise Error, "Unsupported insert arguments"
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/filter_compiler"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # Aggregates and DISTINCT, which Notion's API does not compute:
8
+ # worked out in Ruby over the rows the query returns, so their
9
+ # cost is that of reading every one. As in SQL, an aggregate skips
10
+ # NULLs and is NULL over no value, and DISTINCT comes before
11
+ # OFFSET and LIMIT.
12
+ module DatasetAggregates
13
+ AGGREGATES = {
14
+ sum: ->(v) { v.sum unless v.empty? },
15
+ avg: ->(v) { v.sum.fdiv(v.size) unless v.empty? },
16
+ min: :min.to_proc,
17
+ max: :max.to_proc
18
+ }.freeze
19
+
20
+ # Rows, or the non-NULL values of one column
21
+ def count(*args, &block)
22
+ if block || args.size > 1
23
+ raise Error, "Notion datasets count rows or one column"
24
+ end
25
+
26
+ return budgeted { count_rows } if args.empty?
27
+
28
+ client_side!("count(#{args.first.inspect})")
29
+ budgeted { column_values(args.first).size }
30
+ end
31
+
32
+ private
33
+
34
+ def count_rows
35
+ return to_enum(:fetch_rows, select_sql).count if computed?
36
+
37
+ n = 0
38
+ each_notion_page { n += 1 }
39
+ n
40
+ end
41
+
42
+ # Sequel's sum, avg, min and max all land here
43
+ def _aggregate(function, arg)
44
+ client_side!(function.to_s)
45
+ values = budgeted { column_values(arg) }
46
+ AGGREGATES.fetch(function).call(values)
47
+ rescue TypeError, ArgumentError
48
+ raise Error, "cannot compute #{function} of #{arg.inspect}"
49
+ end
50
+
51
+ def distinct_on? = !@opts[:distinct].empty?
52
+
53
+ def distinct_source(select = @opts[:select])
54
+ clone(distinct: nil, limit: nil, offset: nil, select:)
55
+ .naked.all
56
+ end
57
+
58
+ def first_of_each_key
59
+ keys = @opts[:distinct].map { group_column(it) }
60
+ sel = selection
61
+ distinct_source(nil).uniq { |row| keys.map { row[it] } }
62
+ .map { project(it, sel) }
63
+ end
64
+
65
+ # A column's values, read under a hidden name so a qualified
66
+ # column keeps its table
67
+ def column_values(arg)
68
+ FilterCompiler.property_name(arg)
69
+ naked.select(Sequel.as(arg, :__value)).map(:__value).compact
70
+ end
71
+
72
+ # Every row first, then OFFSET and LIMIT over the distinct
73
+ # ones; DISTINCT ON keeps the first row of each key
74
+ def distinct_rows(&)
75
+ rows = distinct_on? ? first_of_each_key : distinct_source.uniq
76
+ rows = rows.drop(@opts[:offset] || 0)
77
+ rows = rows.take(@opts[:limit]) if @opts[:limit]
78
+ rows.each(&)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # UNION, INTERSECT and EXCEPT, which Notion's API does not
6
+ # compute: each query's rows are read, then combined in Ruby as
7
+ # SQL does (UNION ALL keeps repeats, the others drop them).
8
+ # Sequel wraps the left query in a subquery; ORDER, OFFSET,
9
+ # LIMIT and a SELECT of columns on the result apply to the
10
+ # combined rows.
11
+ module DatasetCompounds
12
+ # Clauses the wrapping query cannot add to a combination
13
+ OUTER_UNSUPPORTED = %i[where group having distinct join].freeze
14
+
15
+ private
16
+
17
+ # A subquery that combines nothing (from_self) is no compound,
18
+ # and gets source_table's refusal
19
+ def compound?
20
+ from = @opts[:from]
21
+ from&.size == 1 && from.first.is_a?(SQL::AliasedExpression) &&
22
+ from.first.expression.is_a?(Notion::Dataset) &&
23
+ !from.first.expression.opts[:compounds].nil?
24
+ end
25
+
26
+ def compound_inner = @opts[:from].first.expression
27
+
28
+ def compound_rows
29
+ bad = OUTER_UNSUPPORTED.select { @opts[it] }
30
+ unless bad.empty?
31
+ raise Error, "a combined query takes no #{bad.join(", ")}"
32
+ end
33
+
34
+ sel = compound_selection
35
+ rows = sort_rows(combined_rows).drop(@opts[:offset] || 0)
36
+ rows = rows.take(@opts[:limit]) if @opts[:limit]
37
+ rows.each { yield project(it, sel) }
38
+ end
39
+
40
+ def compound_columns
41
+ compound_selection&.map(&:last) || compound_inner.columns
42
+ end
43
+
44
+ # The outer SELECT, of columns the combined rows have
45
+ def compound_selection
46
+ sel = @opts[:select]
47
+ return if sel.nil? || sel.empty?
48
+ return if sel.any? { it == :* || it.is_a?(SQL::ColumnAll) }
49
+
50
+ pairs = sel.map { selected(it) }
51
+ unknown = pairs.map(&:first) - compound_inner.columns
52
+ return pairs if unknown.empty?
53
+
54
+ raise Error, "Unknown columns in select: #{unknown.join(", ")}"
55
+ end
56
+
57
+ def combined_rows
58
+ first = compound_inner.clone(compounds: nil)
59
+ names = first.columns
60
+ rows = first.naked.all
61
+ compound_inner.opts[:compounds].reduce(rows) do |left, step|
62
+ op, other, all = step
63
+ combined(op, left, renamed(other, names), all)
64
+ end
65
+ end
66
+
67
+ # Another query's rows under the first query's column names,
68
+ # matched by position, as SQL names a combination's columns
69
+ def renamed(other, names)
70
+ columns = other.columns
71
+ unless columns.size == names.size
72
+ raise Error, "combined queries select #{names.size} " \
73
+ "and #{columns.size} columns"
74
+ end
75
+
76
+ other.naked.all.map do |row|
77
+ names.zip(columns).to_h { |name, col| [name, row[col]] }
78
+ end
79
+ end
80
+
81
+ def combined(op, left, right, all)
82
+ return all ? left + right : (left + right).uniq if op == :union
83
+ raise Error, "#{op.upcase} ALL is not supported" if all
84
+
85
+ op == :intersect ? left.uniq & right : left.uniq - right
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # Rows Notion's API cannot compute (combined queries, groups,
6
+ # DISTINCT, joins), worked out in Ruby by DatasetCompounds,
7
+ # DatasetGrouping, DatasetAggregates and DatasetJoins; ORDER over
8
+ # such rows is sorted here, empty values last, as Notion sorts
9
+ # them. Such a query reads every row it matches, so it runs only
10
+ # on a dataset that opts in with client_side; otherwise it raises
11
+ # before the first request.
12
+ module DatasetComputed
13
+ # Allow what Notion cannot compute to be computed in Ruby;
14
+ # max_requests caps the Notion requests each query may send
15
+ def client_side(max_requests: nil)
16
+ clone(client_side: true, max_requests:)
17
+ end
18
+
19
+ private
20
+
21
+ # Sequel wraps the left query of a UNION, which keeps the opt-in
22
+ def client_side?
23
+ @opts[:client_side] ||
24
+ (compound? && compound_inner.opts[:client_side])
25
+ end
26
+
27
+ def client_side!(what)
28
+ return if client_side?
29
+
30
+ raise Error, "#{what} is computed in Ruby over every row " \
31
+ "the query returns; call client_side on the " \
32
+ "dataset to allow it"
33
+ end
34
+
35
+ def computed_operation
36
+ compounds = compound_inner.opts[:compounds] if compound?
37
+ return compounds.first.first.upcase.to_s if compounds
38
+ return "GROUP BY" if @opts[:group]
39
+ return "DISTINCT" if @opts[:distinct]
40
+
41
+ "JOIN"
42
+ end
43
+
44
+ # Rows Notion cannot compute, worked out in Ruby
45
+ def computed?
46
+ compound? || @opts[:group] || @opts[:distinct] || joined?
47
+ end
48
+
49
+ # Groups and DISTINCT over joined rows: the join comes last
50
+ def computed_rows(&)
51
+ raise Error, "Notion datasets do not support: lock" if
52
+ @opts[:lock]
53
+
54
+ client_side!(computed_operation)
55
+ return compound_rows(&) if compound?
56
+ return grouped_rows(&) if @opts[:group]
57
+ return distinct_rows(&) if @opts[:distinct]
58
+
59
+ join_rows(&)
60
+ end
61
+
62
+ def computed_columns
63
+ return compound_columns if compound?
64
+ return grouped_outputs.map(&:first) if @opts[:group]
65
+
66
+ join_columns_out(join_sources) if joined?
67
+ end
68
+
69
+ # The query's rows, computed in Ruby or read from Notion, each
70
+ # given to the caller outside the query's request budget
71
+ def each_row
72
+ give = ->(row) { db.outside_request_budget { yield row } }
73
+ computed? ? computed_rows(&give) : page_rows(&give)
74
+ end
75
+
76
+ # Pages fetched one by one come in the list's order: ORDER
77
+ # sorts them here, as Notion sorts a query's, after the sort
78
+ # compiler has refused what Notion would
79
+ def ordered_pages(pages)
80
+ SortCompiler.compile(@opts[:order])
81
+ rows = pages.map { TypeMap.page_to_row(it).merge(__page: it) }
82
+ sort_rows(rows).map { it[:__page] }
83
+ end
84
+
85
+ # ORDER over rows already computed (groups, combined rows)
86
+ def sort_rows(rows)
87
+ order = Array(@opts[:order]).map { grouped_order(it) }
88
+ return rows if order.empty?
89
+
90
+ rows.sort do |a, b|
91
+ order.lazy.map { |col, desc| compare(a[col], b[col], desc) }
92
+ .find(&:nonzero?) || 0
93
+ end
94
+ end
95
+
96
+ def grouped_order(clause)
97
+ if clause.is_a?(SQL::OrderedExpression)
98
+ return [group_column(clause.expression), clause.descending]
99
+ end
100
+
101
+ [group_column(clause), false]
102
+ end
103
+
104
+ def compare(left, right, desc)
105
+ return (left.nil? ? 0 : -1) if right.nil?
106
+ return 1 if left.nil?
107
+
108
+ order = sortable(left) <=> sortable(right) or
109
+ raise Error, "cannot order #{left.inspect} and " \
110
+ "#{right.inspect}"
111
+ desc ? -order : order
112
+ end
113
+
114
+ # A checkbox sorts false first, as Notion sorts it
115
+ def sortable(value)
116
+ case value
117
+ when false then 0
118
+ when true then 1
119
+ else value
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/dataset_having"
4
+ require "sequel/notion/filter_compiler"
5
+ require "sequel/notion/group_accumulator"
6
+
7
+ module Sequel
8
+ module Notion
9
+ # GROUP BY, which Notion's API does not compute: the rows the
10
+ # query returns are read once, each group keeping one running
11
+ # value per aggregate; ORDER, OFFSET and LIMIT then apply to the
12
+ # groups. Empty values sort last, as in Notion.
13
+ module DatasetGrouping
14
+ include DatasetHaving
15
+
16
+ private
17
+
18
+ def grouped_rows(&)
19
+ rows = sort_rows(groups).drop(@opts[:offset] || 0)
20
+ rows = rows.take(@opts[:limit]) if @opts[:limit]
21
+ rows.each(&)
22
+ end
23
+
24
+ # One row per group, HAVING applied, hidden outputs dropped
25
+ def groups
26
+ outs = grouped_outputs
27
+ funcs = having_functions
28
+ rows = group_rows(outs + having_outputs(funcs))
29
+ rows = rows.select { having?(it, funcs) } if @opts[:having]
30
+ rows.map { it.slice(*outs.map(&:first)) }
31
+ end
32
+
33
+ def group_rows(outs)
34
+ slots = group_slots(outs)
35
+ accumulate(outs, slots).map do |key, accs|
36
+ grouped_row(outs, key, accs.each)
37
+ end
38
+ end
39
+
40
+ def having_outputs(funcs)
41
+ funcs.each_with_index.map do |func, i|
42
+ aggregate_output(func, having_name(i))
43
+ end
44
+ end
45
+
46
+ def group_column(expr) = FilterCompiler.property_name(expr).to_sym
47
+
48
+ # A column's identity: its table when qualified, and its name
49
+ def column_key(expr)
50
+ return [expr.table.to_s, expr.column.to_sym] if
51
+ expr.is_a?(SQL::QualifiedIdentifier)
52
+
53
+ [nil, group_column(expr)]
54
+ end
55
+
56
+ def group_keys = @opts[:group].map { column_key(it) }
57
+
58
+ # [name, :key, column] or [name, function, column (nil for *)]
59
+ def grouped_outputs
60
+ (@opts[:select] || @opts[:group]).map { group_output(it) }
61
+ end
62
+
63
+ def group_output(expr)
64
+ name = expr.alias.to_sym if expr.is_a?(SQL::AliasedExpression)
65
+ expr = expr.expression if name
66
+ return aggregate_output(expr, name) if expr.is_a?(SQL::Function)
67
+
68
+ key = column_key(expr)
69
+ unless group_keys.include?(key)
70
+ raise Error, "#{key.last} is neither grouped nor aggregated"
71
+ end
72
+
73
+ [name || key.last, :key, expr]
74
+ end
75
+
76
+ def aggregate_output(func, name)
77
+ function = func.name
78
+ function = function.value if function.is_a?(SQL::Identifier)
79
+ function = function.to_s.downcase.to_sym
80
+ [name || function, function, aggregate_column(func, function)]
81
+ end
82
+
83
+ # The column aggregated, or nil for count(*)
84
+ def aggregate_column(func, function)
85
+ arg = func.args.first
86
+ if plain_aggregate?(func, function)
87
+ return arg.tap { column_key(it) } unless star?(func)
88
+ return if function == :count
89
+ end
90
+
91
+ raise Error, "Unsupported aggregate: #{func.inspect}"
92
+ end
93
+
94
+ def star?(func)
95
+ func.opts[:*] || func.args.empty? || func.args.first == "*"
96
+ end
97
+
98
+ def plain_aggregate?(func, function)
99
+ GroupAccumulator::FUNCTIONS.include?(function) &&
100
+ func.args.size <= 1 && (func.opts.keys - [:*]).empty?
101
+ end
102
+
103
+ # column key => [column, hidden name it is read under]
104
+ def group_slots(outs)
105
+ columns = @opts[:group] + outs.filter_map(&:last)
106
+ columns.uniq { column_key(it) }.each_with_index
107
+ .to_h { |col, i| [column_key(col), [col, :"__c#{i}"]] }
108
+ end
109
+
110
+ def accumulate(outs, slots)
111
+ aggs = outs.reject { it[1] == :key }
112
+ names = group_keys.map { slots[it].last }
113
+ group_source(slots).each_with_object({}) do |row, groups|
114
+ accs = groups[names.map { row[it] }] ||= accumulators(aggs)
115
+ add_row(row, aggs.zip(accs), slots)
116
+ end
117
+ end
118
+
119
+ def accumulators(aggs) = aggs.map { GroupAccumulator.new(it[1]) }
120
+
121
+ def add_row(row, pairs, slots)
122
+ pairs.each do |(_, _, col), acc|
123
+ acc.add(col ? row[slots[column_key(col)].last] : 1)
124
+ end
125
+ end
126
+
127
+ # The rows to group: the WHERE kept, Notion asked for nothing
128
+ # else, and only the columns the groups need
129
+ def group_source(slots)
130
+ clone(group: nil, select: nil, order: nil, limit: nil,
131
+ offset: nil, distinct: nil, having: nil)
132
+ .naked.select(*slots.values.map { Sequel.as(*it) })
133
+ end
134
+
135
+ def grouped_row(outs, key, accs)
136
+ keys = group_keys
137
+ outs.to_h do |name, kind, col|
138
+ next [name, accs.next.value] unless kind == :key
139
+
140
+ [name, key[keys.index(column_key(col))]]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end