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,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