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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +207 -0
- data/LICENSE.txt +21 -0
- data/README.md +470 -0
- data/lib/sequel/adapters/notion.rb +159 -0
- data/lib/sequel/notion/dataset.rb +167 -0
- data/lib/sequel/notion/dataset_aggregates.rb +82 -0
- data/lib/sequel/notion/dataset_compounds.rb +89 -0
- data/lib/sequel/notion/dataset_computed.rb +124 -0
- data/lib/sequel/notion/dataset_grouping.rb +145 -0
- data/lib/sequel/notion/dataset_having.rb +99 -0
- data/lib/sequel/notion/dataset_joins.rb +124 -0
- data/lib/sequel/notion/dataset_pages.rb +145 -0
- data/lib/sequel/notion/dataset_selection.rb +51 -0
- data/lib/sequel/notion/dataset_truncation.rb +39 -0
- data/lib/sequel/notion/discovery.rb +76 -0
- data/lib/sequel/notion/errors.rb +9 -0
- data/lib/sequel/notion/filter_comparison.rb +123 -0
- data/lib/sequel/notion/filter_compiler.rb +121 -0
- data/lib/sequel/notion/filter_constants.rb +37 -0
- data/lib/sequel/notion/filter_helpers.rb +162 -0
- data/lib/sequel/notion/filter_like.rb +143 -0
- data/lib/sequel/notion/filter_like_tokenizer.rb +42 -0
- data/lib/sequel/notion/filter_negation.rb +76 -0
- data/lib/sequel/notion/filter_nested.rb +96 -0
- data/lib/sequel/notion/filter_nulls.rb +53 -0
- data/lib/sequel/notion/filter_predicates.rb +160 -0
- data/lib/sequel/notion/filter_shape.rb +75 -0
- data/lib/sequel/notion/filter_tables.rb +111 -0
- data/lib/sequel/notion/group_accumulator.rb +55 -0
- data/lib/sequel/notion/join_output.rb +58 -0
- data/lib/sequel/notion/join_where.rb +111 -0
- data/lib/sequel/notion/model_support.rb +52 -0
- data/lib/sequel/notion/notion_file.rb +228 -0
- data/lib/sequel/notion/page_api.rb +49 -0
- data/lib/sequel/notion/registry.rb +150 -0
- data/lib/sequel/notion/request_budget.rb +64 -0
- data/lib/sequel/notion/schema.rb +74 -0
- data/lib/sequel/notion/schema_lookup.rb +82 -0
- data/lib/sequel/notion/sort_compiler.rb +63 -0
- data/lib/sequel/notion/type_map.rb +125 -0
- data/lib/sequel/notion/type_map_builders.rb +125 -0
- data/lib/sequel/notion/type_map_dates.rb +67 -0
- data/lib/sequel/notion/type_map_extractors.rb +89 -0
- data/lib/sequel/notion/version.rb +7 -0
- data/lib/sequel/notion.rb +5 -0
- 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
|