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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# SQL's rule for empty values (rule E), mixed into FilterCompiler
|
|
6
|
+
# and applied once, to the finished filter. Notion's
|
|
7
|
+
# does_not_equal and does_not_contain match an empty property;
|
|
8
|
+
# SQL's != and NOT LIKE never match NULL. Each such leaf gets an
|
|
9
|
+
# is_not_empty beside it: spliced into an enclosing "and", so
|
|
10
|
+
# nesting does not grow, else wrapped in one. It runs after
|
|
11
|
+
# negation, which would otherwise turn the guard into is_empty.
|
|
12
|
+
module FilterNulls
|
|
13
|
+
NEGATIVE_OPS = %w[does_not_equal does_not_contain].freeze
|
|
14
|
+
|
|
15
|
+
# Never empty (checkbox, unique_id), or already excluding
|
|
16
|
+
# empty results (formula, checked live). A rollup is not: its
|
|
17
|
+
# does_not_equal matches an empty one (checked live), so it is
|
|
18
|
+
# guarded under its kind.
|
|
19
|
+
UNGUARDED = %w[checkbox unique_id formula].freeze
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def sql_nulls(filter) = conjunction(guarded(filter))
|
|
24
|
+
|
|
25
|
+
# The conditions an "and" holding +filter+ needs
|
|
26
|
+
def guarded(filter)
|
|
27
|
+
if filter.key?("and")
|
|
28
|
+
[{ "and" => filter["and"].flat_map { guarded(it) }.uniq }]
|
|
29
|
+
elsif filter.key?("or")
|
|
30
|
+
[{ "or" => filter["or"].map { conjunction(guarded(it)) } }]
|
|
31
|
+
else
|
|
32
|
+
guarded_leaf(filter)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def guarded_leaf(leaf)
|
|
37
|
+
key = (leaf.keys - ["property"]).first
|
|
38
|
+
kind = leaf[key].keys.first if key == "rollup"
|
|
39
|
+
cond = kind ? leaf[key][kind] : leaf[key]
|
|
40
|
+
return [leaf] if UNGUARDED.include?(key) ||
|
|
41
|
+
!NEGATIVE_OPS.include?(cond.keys.first)
|
|
42
|
+
|
|
43
|
+
guard = { "is_not_empty" => true }
|
|
44
|
+
[leaf, { "property" => leaf["property"],
|
|
45
|
+
key => kind ? { kind => guard } : guard }]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def conjunction(conds)
|
|
49
|
+
conds.one? ? conds.first : { "and" => conds }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/filter_nested"
|
|
4
|
+
require "sequel/notion/filter_tables"
|
|
5
|
+
|
|
6
|
+
module Sequel
|
|
7
|
+
module Notion
|
|
8
|
+
# Predicate compilers mixed into FilterCompiler: emptiness checks,
|
|
9
|
+
# IS / IS NOT, bare checkbox columns, and IN / NOT IN. All private.
|
|
10
|
+
module FilterPredicates
|
|
11
|
+
# Keys whose IN goes value by value: nested, or a date, which
|
|
12
|
+
# has no does_not_equal
|
|
13
|
+
IN_BY_VALUE = (FilterNested::NESTED_KEYS + ["date"]).freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# ----------------------------------------------------------
|
|
18
|
+
# Null / empty check -> is_empty / is_not_empty
|
|
19
|
+
# ----------------------------------------------------------
|
|
20
|
+
#
|
|
21
|
+
# Single method (dedupe rule) used both for `= nil` / `!= nil`
|
|
22
|
+
# and for `IS nil` / `IS NOT nil`.
|
|
23
|
+
def null_check_filter(name, empty:)
|
|
24
|
+
type = lookup_type!(name)
|
|
25
|
+
key = filter_key_for!(name, type)
|
|
26
|
+
|
|
27
|
+
op = empty ? "is_empty" : "is_not_empty"
|
|
28
|
+
return nested_null_filter(name, key, op) if
|
|
29
|
+
FilterNested::NESTED_KEYS.include?(key)
|
|
30
|
+
|
|
31
|
+
ensure_supported!(name, key, op)
|
|
32
|
+
|
|
33
|
+
{ "property" => name, key => { op => true } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ----------------------------------------------------------
|
|
37
|
+
# IS / IS NOT (rule I)
|
|
38
|
+
# ----------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def compile_is(expr)
|
|
41
|
+
left, value = expr.args
|
|
42
|
+
name = FilterCompiler.property_name(left)
|
|
43
|
+
negated = expr.op == :"IS NOT"
|
|
44
|
+
|
|
45
|
+
case value
|
|
46
|
+
when nil then null_check_filter(name, empty: !negated)
|
|
47
|
+
when true, false then compile_is_boolean(name, negated, value)
|
|
48
|
+
else
|
|
49
|
+
raise unsupported_is_value_error(name, value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unsupported_is_value_error(name, value)
|
|
54
|
+
Sequel::Error.new(
|
|
55
|
+
"Unsupported IS value for property '#{name}': " \
|
|
56
|
+
"#{value.inspect}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A checkbox, or a formula through its nested checkbox key, as
|
|
61
|
+
# for a bare column
|
|
62
|
+
def compile_is_boolean(name, negated, value)
|
|
63
|
+
type = lookup_type!(name)
|
|
64
|
+
case filter_key_for!(name, type)
|
|
65
|
+
when "checkbox"
|
|
66
|
+
op = negated ? "does_not_equal" : "equals"
|
|
67
|
+
{ "property" => name, "checkbox" => { op => value } }
|
|
68
|
+
when "formula"
|
|
69
|
+
op = negated ? :"!=" : :"="
|
|
70
|
+
compile_formula_comparison(name, op, value)
|
|
71
|
+
else raise boolean_type_error(name, type, value)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def boolean_type_error(name, type, value)
|
|
76
|
+
Sequel::Error.new("'#{name}' IS #{value}: a checkbox or " \
|
|
77
|
+
"formula property only, got type '#{type}'")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def contains_type?(key)
|
|
81
|
+
FilterTables::CONTAINS_TYPES.include?(key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ----------------------------------------------------------
|
|
85
|
+
# Bare column (rule B)
|
|
86
|
+
# ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# A checkbox, or a formula through its checkbox key
|
|
89
|
+
def compile_bare_column(expr, value: true)
|
|
90
|
+
name = FilterCompiler.property_name(expr)
|
|
91
|
+
type = lookup_type!(name)
|
|
92
|
+
case filter_key_for!(name, type)
|
|
93
|
+
when "checkbox"
|
|
94
|
+
{ "property" => name, "checkbox" => { "equals" => value } }
|
|
95
|
+
when "formula"
|
|
96
|
+
compile_formula_comparison(name, :"=", value)
|
|
97
|
+
else raise bare_column_error(name, type)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bare_column_error(name, type)
|
|
102
|
+
Sequel::Error.new("Bare column '#{name}' must be a checkbox " \
|
|
103
|
+
"or formula property, got type '#{type}'")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ----------------------------------------------------------
|
|
107
|
+
# IN / NOT IN (rule IN)
|
|
108
|
+
# ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def compile_in(expr)
|
|
111
|
+
left, value = expr.args
|
|
112
|
+
name = FilterCompiler.property_name(left)
|
|
113
|
+
validate_in_array!(value, name)
|
|
114
|
+
|
|
115
|
+
key = filter_key_for!(name, lookup_type!(name))
|
|
116
|
+
return empty_in(expr.op) if value.empty?
|
|
117
|
+
return in_by_value(expr.op, name, value, key) if
|
|
118
|
+
IN_BY_VALUE.include?(key)
|
|
119
|
+
|
|
120
|
+
base_op = in_base_operator(expr.op, key)
|
|
121
|
+
ensure_supported!(name, key, base_op)
|
|
122
|
+
|
|
123
|
+
in_filter(expr.op, name, key, base_op, value)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_in_array!(value, name)
|
|
127
|
+
return if value.is_a?(Array)
|
|
128
|
+
|
|
129
|
+
raise Sequel::Error,
|
|
130
|
+
"IN/NOT IN requires an Array value for " \
|
|
131
|
+
"property '#{name}', got #{value.class}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# IN () matches no page, NOT IN () every page
|
|
135
|
+
def empty_in(sequel_op)
|
|
136
|
+
return FilterConstants::NOTHING if sequel_op == :IN
|
|
137
|
+
|
|
138
|
+
FilterConstants::EVERYTHING
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def in_base_operator(sequel_op, key)
|
|
142
|
+
base_op = sequel_op == :IN ? "equals" : "does_not_equal"
|
|
143
|
+
contains_type?(key) ? equal_to_contains(base_op) : base_op
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A nil in the list means empty, as `where(P: nil)` does
|
|
147
|
+
def in_filter(sequel_op, name, key, base_op, value)
|
|
148
|
+
leaves = value.map do |v|
|
|
149
|
+
next null_check_filter(name, empty: sequel_op == :IN) if
|
|
150
|
+
v.nil?
|
|
151
|
+
|
|
152
|
+
{ "property" => name,
|
|
153
|
+
key => { base_op => coerce_value(v, key) } }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
{ (sequel_op == :IN ? "or" : "and") => leaves }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# The finished filter, fitted to Notion's shape (rule K), mixed
|
|
6
|
+
# into FilterCompiler. Notion nests and/or two levels deep at
|
|
7
|
+
# most. An "and" directly inside an "and" (or an "or" inside an
|
|
8
|
+
# "or") is spliced into its parent; a filter still too deep has
|
|
9
|
+
# its inner level distributed, (a & b) | c becoming
|
|
10
|
+
# (a | c) & (b | c), up to MAX_CLAUSES clauses; what remains too
|
|
11
|
+
# deep raises rather than being sent to fail with a 400.
|
|
12
|
+
module FilterShape
|
|
13
|
+
MAX_DEPTH = 2
|
|
14
|
+
MAX_CLAUSES = 32
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def notion_shape(filter)
|
|
19
|
+
flat = flattened(filter)
|
|
20
|
+
flat = distributed(flat) if compound_depth(flat) > MAX_DEPTH
|
|
21
|
+
return flat if compound_depth(flat) <= MAX_DEPTH
|
|
22
|
+
|
|
23
|
+
raise too_deep(flat)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flattened(filter)
|
|
27
|
+
op = compound_op(filter)
|
|
28
|
+
return filter unless op
|
|
29
|
+
|
|
30
|
+
{ op => filter[op].flat_map do |kid|
|
|
31
|
+
kid = flattened(kid)
|
|
32
|
+
compound_op(kid) == op ? kid[op] : [kid]
|
|
33
|
+
end }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Each kid two levels deep, whose op is the other one,
|
|
37
|
+
# rewritten in the root's op and spliced into the root
|
|
38
|
+
def distributed(filter)
|
|
39
|
+
op = compound_op(filter)
|
|
40
|
+
{ op => filter[op].flat_map do |kid|
|
|
41
|
+
compound_depth(kid) == MAX_DEPTH ? swapped(kid, op) : [kid]
|
|
42
|
+
end }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The clauses of kid, an "or" of "and"s (or the reverse),
|
|
46
|
+
# rewritten as op's children
|
|
47
|
+
def swapped(kid, op)
|
|
48
|
+
kop = compound_op(kid)
|
|
49
|
+
choices = kid[kop].map { compound_op(it) == op ? it[op] : [it] }
|
|
50
|
+
clauses = choices.first.product(*choices.drop(1))
|
|
51
|
+
raise too_deep(kid) if clauses.size > MAX_CLAUSES
|
|
52
|
+
|
|
53
|
+
clauses.map { conjoined(kop, it.uniq) }.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conjoined(op, conds)
|
|
57
|
+
conds.one? ? conds.first : { op => conds }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def too_deep(filter)
|
|
61
|
+
Sequel::Error.new("Notion nests and/or at most #{MAX_DEPTH} " \
|
|
62
|
+
"levels deep: #{filter.inspect}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def compound_depth(filter)
|
|
66
|
+
op = compound_op(filter)
|
|
67
|
+
op ? 1 + filter[op].map { compound_depth(it) }.max : 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def compound_op(filter)
|
|
71
|
+
%w[and or].find { filter.key?(it) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# Static lookup tables shared by the filter-compiling mixins
|
|
6
|
+
# (FilterHelpers, FilterPredicates, FilterLike, FilterComparison,
|
|
7
|
+
# FilterNegation). Pure data, no behaviour.
|
|
8
|
+
module FilterTables
|
|
9
|
+
OPERATOR_MAP = {
|
|
10
|
+
:"=" => "equals",
|
|
11
|
+
:"!=" => "does_not_equal",
|
|
12
|
+
:> => "greater_than",
|
|
13
|
+
:< => "less_than",
|
|
14
|
+
:>= => "greater_than_or_equal_to",
|
|
15
|
+
:<= => "less_than_or_equal_to"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Mirrors an operator when the comparison's operands are swapped
|
|
19
|
+
# (rule S): a < b == b > a
|
|
20
|
+
MIRROR_OPERATOR = {
|
|
21
|
+
:< => :>,
|
|
22
|
+
:> => :<,
|
|
23
|
+
:<= => :>=,
|
|
24
|
+
:>= => :<=,
|
|
25
|
+
:"=" => :"=",
|
|
26
|
+
:"!=" => :"!="
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
FILTER_TYPE_KEY = {
|
|
30
|
+
"title" => "rich_text",
|
|
31
|
+
"rich_text" => "rich_text",
|
|
32
|
+
"url" => "rich_text",
|
|
33
|
+
"email" => "rich_text",
|
|
34
|
+
"phone_number" => "phone_number",
|
|
35
|
+
"created_time" => "date",
|
|
36
|
+
"last_edited_time" => "date",
|
|
37
|
+
"date" => "date",
|
|
38
|
+
"number" => "number",
|
|
39
|
+
"select" => "select",
|
|
40
|
+
"multi_select" => "multi_select",
|
|
41
|
+
"status" => "status",
|
|
42
|
+
"checkbox" => "checkbox",
|
|
43
|
+
"people" => "people",
|
|
44
|
+
"relation" => "relation",
|
|
45
|
+
"files" => "files",
|
|
46
|
+
"formula" => "formula",
|
|
47
|
+
"unique_id" => "unique_id",
|
|
48
|
+
"rollup" => "rollup"
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
RICH_TEXT_OPS = %w[equals does_not_equal contains does_not_contain
|
|
52
|
+
starts_with ends_with is_empty
|
|
53
|
+
is_not_empty].freeze
|
|
54
|
+
|
|
55
|
+
SUPPORTED_OPS = {
|
|
56
|
+
"rich_text" => RICH_TEXT_OPS,
|
|
57
|
+
"string" => RICH_TEXT_OPS, # formula inner key for String values
|
|
58
|
+
"number" => %w[equals does_not_equal greater_than less_than
|
|
59
|
+
greater_than_or_equal_to
|
|
60
|
+
less_than_or_equal_to is_empty is_not_empty],
|
|
61
|
+
"select" => %w[equals does_not_equal is_empty is_not_empty],
|
|
62
|
+
"multi_select" => %w[contains does_not_contain is_empty
|
|
63
|
+
is_not_empty],
|
|
64
|
+
"status" => %w[equals does_not_equal is_empty is_not_empty],
|
|
65
|
+
"date" => %w[equals before after on_or_before on_or_after
|
|
66
|
+
is_empty is_not_empty],
|
|
67
|
+
"checkbox" => %w[equals does_not_equal],
|
|
68
|
+
"phone_number" => %w[equals does_not_equal contains
|
|
69
|
+
does_not_contain starts_with ends_with
|
|
70
|
+
is_empty is_not_empty],
|
|
71
|
+
"people" => %w[contains does_not_contain is_empty
|
|
72
|
+
is_not_empty],
|
|
73
|
+
"relation" => %w[contains does_not_contain is_empty
|
|
74
|
+
is_not_empty],
|
|
75
|
+
"files" => %w[is_empty is_not_empty],
|
|
76
|
+
"unique_id" => %w[equals does_not_equal greater_than less_than
|
|
77
|
+
greater_than_or_equal_to
|
|
78
|
+
less_than_or_equal_to]
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
DATE_OPERATOR_MAP = {
|
|
82
|
+
"greater_than" => "after",
|
|
83
|
+
"less_than" => "before",
|
|
84
|
+
"greater_than_or_equal_to" => "on_or_after",
|
|
85
|
+
"less_than_or_equal_to" => "on_or_before"
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# Explicit inverse table for negation (rule G).
|
|
89
|
+
NEGATE_MAP = {
|
|
90
|
+
"equals" => "does_not_equal",
|
|
91
|
+
"does_not_equal" => "equals",
|
|
92
|
+
"contains" => "does_not_contain",
|
|
93
|
+
"does_not_contain" => "contains",
|
|
94
|
+
"is_empty" => "is_not_empty",
|
|
95
|
+
"is_not_empty" => "is_empty",
|
|
96
|
+
"greater_than" => "less_than_or_equal_to",
|
|
97
|
+
"less_than_or_equal_to" => "greater_than",
|
|
98
|
+
"less_than" => "greater_than_or_equal_to",
|
|
99
|
+
"greater_than_or_equal_to" => "less_than",
|
|
100
|
+
"before" => "on_or_after",
|
|
101
|
+
"on_or_after" => "before",
|
|
102
|
+
"after" => "on_or_before",
|
|
103
|
+
"on_or_before" => "after"
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
# Property types whose (not)equal comparisons are expressed
|
|
107
|
+
# through contains/does_not_contain instead (rule IN).
|
|
108
|
+
CONTAINS_TYPES = %w[multi_select people relation].freeze
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# One aggregate over one group, kept as a running value rather
|
|
6
|
+
# than the group's rows: count, sum, avg, min or max of the
|
|
7
|
+
# non-NULL values, as SQL defines them. count(*) is a count of a
|
|
8
|
+
# value given once per row.
|
|
9
|
+
class GroupAccumulator
|
|
10
|
+
FUNCTIONS = %i[count sum avg min max].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(function)
|
|
13
|
+
@function = function
|
|
14
|
+
@count = 0
|
|
15
|
+
@value = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(value)
|
|
19
|
+
return if value.nil?
|
|
20
|
+
|
|
21
|
+
numeric!(value)
|
|
22
|
+
@count += 1
|
|
23
|
+
@value = @value.nil? ? value : combined(value)
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
raise Error, "cannot compute #{@function} of #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def value
|
|
29
|
+
case @function
|
|
30
|
+
when :count then @count
|
|
31
|
+
when :avg then @value&.fdiv(@count)
|
|
32
|
+
else @value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def numeric!(value)
|
|
39
|
+
return if value.is_a?(Numeric)
|
|
40
|
+
return unless %i[sum avg].include?(@function)
|
|
41
|
+
|
|
42
|
+
raise Error, "cannot compute #{@function} of #{value.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def combined(value)
|
|
46
|
+
case @function
|
|
47
|
+
when :sum, :avg then @value + value
|
|
48
|
+
when :min then [@value, value].min
|
|
49
|
+
when :max then [@value, value].max
|
|
50
|
+
else @value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/filter_compiler"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# A joined row ({ source name => row }) as the row a query
|
|
8
|
+
# returns, mixed into DatasetJoins: the selected columns, or every
|
|
9
|
+
# column with a later table winning a shared name.
|
|
10
|
+
module JoinOutput
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def join_output(row, sources)
|
|
14
|
+
sel = @opts[:select]
|
|
15
|
+
return merged_row(row, sources) if sel.nil? || sel.empty?
|
|
16
|
+
|
|
17
|
+
sel.to_h { join_selected(it, row, sources) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def merged_row(row, sources)
|
|
21
|
+
sources.each_with_object({}) do |(name, table), out|
|
|
22
|
+
out.merge!(row[name] || null_row(table))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def null_row(table) = db.schema(table).to_h { [it.first, nil] }
|
|
27
|
+
|
|
28
|
+
def join_selected(expr, row, sources)
|
|
29
|
+
name = expr.alias.to_sym if expr.is_a?(SQL::AliasedExpression)
|
|
30
|
+
expr = expr.expression if name
|
|
31
|
+
source, column = join_column(expr, sources)
|
|
32
|
+
[name || column, row[source]&.[](column)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def join_column(expr, sources)
|
|
36
|
+
case expr
|
|
37
|
+
when SQL::QualifiedIdentifier
|
|
38
|
+
[qualified(expr, sources), expr.column.to_sym]
|
|
39
|
+
when Symbol, SQL::Identifier
|
|
40
|
+
[owner(expr, sources),
|
|
41
|
+
FilterCompiler.property_name(expr).to_sym]
|
|
42
|
+
else
|
|
43
|
+
raise Error, "only columns can be selected from a " \
|
|
44
|
+
"join: #{expr.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def join_columns_out(sources)
|
|
49
|
+
sel = @opts[:select]
|
|
50
|
+
return sel.map { join_selected(it, {}, sources).first } if
|
|
51
|
+
sel && !sel.empty?
|
|
52
|
+
|
|
53
|
+
sources.flat_map { |_, table| db.schema(table).map(&:first) }
|
|
54
|
+
.uniq
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/filter_compiler"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# Which side of a join each column and each WHERE condition
|
|
8
|
+
# belongs to, mixed into DatasetJoins. A source is
|
|
9
|
+
# [name, table]: the alias, or the table's own name. An
|
|
10
|
+
# unqualified column belongs to the one source whose schema has
|
|
11
|
+
# it. A condition testing one source runs in Notion, on it; one
|
|
12
|
+
# testing two has no Notion filter and raises.
|
|
13
|
+
#
|
|
14
|
+
# WHERE runs after the join. On an inner join, filtering a side
|
|
15
|
+
# first gives the same rows; on a left join it does not, since a
|
|
16
|
+
# row whose partner fails the condition would be kept with an
|
|
17
|
+
# empty side. So a left join's own conditions filter its partners
|
|
18
|
+
# after matching on the whole table, and a row with no partner is
|
|
19
|
+
# kept only if an empty page passes them, as NULLs do in SQL.
|
|
20
|
+
module JoinWhere
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def left_joined(rows, left, right, source, wheres)
|
|
24
|
+
name, = source
|
|
25
|
+
index = join_index(source_rows(source, {}), right)
|
|
26
|
+
kept = source_rows(source, wheres).to_set { it[:id] }
|
|
27
|
+
rows.flat_map do |row|
|
|
28
|
+
found = partners(row, *left, index)
|
|
29
|
+
next empty_side(row, source, wheres) if found.empty?
|
|
30
|
+
|
|
31
|
+
found.select { kept.include?(it[:id]) }
|
|
32
|
+
.map { row.merge(name => it) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty_side(row, (name, table), wheres)
|
|
37
|
+
cond = wheres[name].reduce(db[table]) { |ds, c| ds.where(c) }
|
|
38
|
+
filter = FilterCompiler.for(db, db.data_source_id_for(table))
|
|
39
|
+
.compile(cond.opts[:where])
|
|
40
|
+
empty_match?(filter) ? [row.merge(name => nil)] : []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether an empty page passes a compiled filter: only an
|
|
44
|
+
# is_empty test does (negations carry is_not_empty)
|
|
45
|
+
def empty_match?(filter)
|
|
46
|
+
return true if filter.nil?
|
|
47
|
+
return false if filter.equal?(FilterCompiler::NOTHING)
|
|
48
|
+
|
|
49
|
+
return filter["and"].all? { empty_match?(it) } if filter["and"]
|
|
50
|
+
return filter["or"].any? { empty_match?(it) } if filter["or"]
|
|
51
|
+
|
|
52
|
+
empty_test?(filter)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty_test?(value)
|
|
56
|
+
value.is_a?(Hash) && (value.key?("is_empty") ||
|
|
57
|
+
value.values.any? { empty_test?(it) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# name => the conditions to send with that source's query
|
|
61
|
+
def split_where(sources)
|
|
62
|
+
conjuncts(@opts[:where]).group_by do |cond|
|
|
63
|
+
owners = referenced(cond, sources)
|
|
64
|
+
if owners.size > 1
|
|
65
|
+
raise Error, "a join's where tests one table per " \
|
|
66
|
+
"condition: #{cond.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
owners.first || sources.first.first
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def conjuncts(expr)
|
|
74
|
+
return [] if expr.nil?
|
|
75
|
+
if expr.is_a?(SQL::BooleanExpression) && expr.op == :AND
|
|
76
|
+
return expr.args.flat_map { conjuncts(it) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
[expr]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def referenced(expr, sources)
|
|
83
|
+
case expr
|
|
84
|
+
when SQL::QualifiedIdentifier then [qualified(expr, sources)]
|
|
85
|
+
when Symbol, SQL::Identifier then [owner(expr, sources)]
|
|
86
|
+
when SQL::ComplexExpression then referenced(expr.args, sources)
|
|
87
|
+
when Array then expr.flat_map { referenced(it, sources) }.uniq
|
|
88
|
+
else []
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def qualified(expr, sources)
|
|
93
|
+
name = expr.table.to_s
|
|
94
|
+
return name if sources.any? { it.first == name }
|
|
95
|
+
|
|
96
|
+
raise Error, "unknown table in a join: #{name}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def owner(expr, sources)
|
|
100
|
+
column = FilterCompiler.property_name(expr).to_sym
|
|
101
|
+
found = sources.select do |_, table|
|
|
102
|
+
db.schema(table).any? { it.first == column }
|
|
103
|
+
end
|
|
104
|
+
return found.first.first if found.one?
|
|
105
|
+
|
|
106
|
+
raise Error, "column #{column} is in #{found.size} of the " \
|
|
107
|
+
"joined tables; qualify it"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# Sequel::Model precomputes SQL for primary key lookups and
|
|
6
|
+
# deletes on "simple" tables, and runs it outside the dataset.
|
|
7
|
+
# A model over a Notion dataset is never simple, so those go
|
|
8
|
+
# through where(id: ...) like any other query.
|
|
9
|
+
module ModelSupport
|
|
10
|
+
# The model's dataset, allowing what Notion cannot compute to
|
|
11
|
+
# be computed in Ruby: Task.client_side.sum(:Hours)
|
|
12
|
+
def client_side(...) = dataset.client_side(...)
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def convert_input_dataset(source)
|
|
17
|
+
ds = super
|
|
18
|
+
self.simple_table = nil if ds.is_a?(Notion::Dataset)
|
|
19
|
+
ds
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A row reads back partial values (a date's start only, the first
|
|
24
|
+
# 25 relations or people, rich text as plain text), so a save
|
|
25
|
+
# writes back only the columns that were changed, never the
|
|
26
|
+
# whole row.
|
|
27
|
+
module ModelSaveSupport
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def _save_update_all_columns_hash
|
|
31
|
+
return super unless model.dataset.is_a?(Notion::Dataset)
|
|
32
|
+
|
|
33
|
+
_save_update_changed_columns_hash
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Notion cannot sort by page id: a model over a Notion dataset
|
|
38
|
+
# adds no primary key order, so paged_each streams in Notion's
|
|
39
|
+
# order and last needs an explicit one.
|
|
40
|
+
module ModelOrderSupport
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def _primary_key_order
|
|
44
|
+
super unless is_a?(Notion::Dataset)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Sequel::Model::ClassMethods.prepend(ModelSupport)
|
|
49
|
+
Sequel::Model::DatasetMethods.prepend(ModelOrderSupport)
|
|
50
|
+
Sequel::Model::InstanceMethods.prepend(ModelSaveSupport)
|
|
51
|
+
end
|
|
52
|
+
end
|