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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/filter_constants"
4
+ require "sequel/notion/filter_tables"
5
+ require "sequel/notion/filter_helpers"
6
+ require "sequel/notion/filter_predicates"
7
+ require "sequel/notion/filter_like_tokenizer"
8
+ require "sequel/notion/filter_like"
9
+ require "sequel/notion/filter_comparison"
10
+ require "sequel/notion/filter_nested"
11
+ require "sequel/notion/filter_negation"
12
+ require "sequel/notion/filter_nulls"
13
+ require "sequel/notion/filter_shape"
14
+
15
+ module Sequel
16
+ module Notion
17
+ class FilterCompiler
18
+ include FilterConstants
19
+ include FilterHelpers
20
+ include FilterPredicates
21
+ include FilterLikeTokenizer
22
+ include FilterLike
23
+ include FilterComparison
24
+ include FilterNested
25
+ include FilterNegation
26
+ include FilterNulls
27
+ include FilterShape
28
+
29
+ # ----------------------------------------------------------
30
+ # Property name resolution (rule N)
31
+ # ----------------------------------------------------------
32
+ #
33
+ # Turns whatever Sequel hands us for a property reference into
34
+ # the plain String name Notion expects.
35
+ def self.property_name(expr)
36
+ case expr
37
+ when Symbol, String then return expr.to_s
38
+ when Sequel::SQL::Identifier then return expr.value.to_s
39
+ when Sequel::SQL::QualifiedIdentifier
40
+ return property_name(expr.column)
41
+ end
42
+
43
+ raise Sequel::Error,
44
+ "Unsupported property name expression: " \
45
+ "#{expr.inspect}"
46
+ end
47
+
48
+ # The compiler for one data source of a Database
49
+ def self.for(db, ds_id)
50
+ new(db.property_type_map(ds_id), db.rollup_kinds(ds_id))
51
+ end
52
+
53
+ # schema: { "Status" => "status", "Name" => "title", ... };
54
+ # rollups: rollup name => the kind of value its function gives
55
+ # ("number", "date", or "array" for every value)
56
+ def initialize(schema, rollups = {})
57
+ @prop_types = schema
58
+ @rollups = rollups
59
+ end
60
+
61
+ # The Notion filter for a WHERE: nil when every page meets it
62
+ # (send none), NOTHING when no page does (send no request)
63
+ def compile(expr)
64
+ filter = compile_node(expr)
65
+ return (filter.equal?(NOTHING) ? NOTHING : nil) if
66
+ constant?(filter)
67
+
68
+ notion_shape(sql_nulls(filter))
69
+ end
70
+
71
+ private
72
+
73
+ def compile_node(expr) = compile_expr(unwrap_noop(expr))
74
+
75
+ def compile_expr(expr)
76
+ case expr
77
+ when Sequel::SQL::BooleanExpression then compile_boolean(expr)
78
+ when Sequel::SQL::ComplexExpression
79
+ compile_comparison(expr)
80
+ when Symbol, Sequel::SQL::Identifier,
81
+ Sequel::SQL::QualifiedIdentifier
82
+ compile_bare_column(expr)
83
+ else
84
+ raise Sequel::Error, "Unsupported filter: #{expr.class}"
85
+ end
86
+ end
87
+
88
+ # Sequel wraps some conditions in a one-argument NOOP
89
+ def unwrap_noop(expr)
90
+ while expr.is_a?(Sequel::SQL::BooleanExpression) &&
91
+ expr.op == :NOOP && expr.args.size == 1
92
+ expr = expr.args.first
93
+ end
94
+ expr
95
+ end
96
+
97
+ # AND/OR/NOT are rule N's boolean connectives; IS/IS NOT is
98
+ # Sequel's `Sequel.expr(col => nil/true/false)` form (rule I).
99
+ def compile_boolean(expr)
100
+ case expr.op
101
+ when :AND then all_of(expr.args.map { compile_node(it) })
102
+ when :OR then any_of(expr.args.map { compile_node(it) })
103
+ when :NOT then compile_not(expr.args.first)
104
+ when :IS, :"IS NOT" then compile_is(expr)
105
+ else
106
+ compile_comparison(expr)
107
+ end
108
+ end
109
+
110
+ # Rule N's bare-column reading of NOT: `NOT checkbox_col` means
111
+ # the checkbox is false, not "negate whatever this compiles to".
112
+ def compile_not(inner)
113
+ if bare_column_expr?(inner)
114
+ compile_bare_column(inner, value: false)
115
+ else
116
+ negate(compile_node(inner))
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # Conditions every page meets, or none does, mixed into
6
+ # FilterCompiler. An empty list compiles to one, as Sequel's SQL
7
+ # adapters read it (IN () is false, NOT IN () true); Notion has no
8
+ # such filter, so and/or fold them away, NOT swaps them, and only
9
+ # a whole filter can be one: no filter sent, or no request.
10
+ module FilterConstants
11
+ EVERYTHING = { "everything" => true }.freeze
12
+ NOTHING = { "nothing" => true }.freeze
13
+
14
+ private
15
+
16
+ def constant?(filter)
17
+ filter.equal?(EVERYTHING) || filter.equal?(NOTHING)
18
+ end
19
+
20
+ def opposite(filter) = filter.equal?(NOTHING) ? EVERYTHING : NOTHING
21
+
22
+ # An and of compiled parts: NOTHING if one is, the EVERYTHING
23
+ # ones dropped; an or the other way round
24
+ def all_of(parts) = folded("and", parts, NOTHING, EVERYTHING)
25
+ def any_of(parts) = folded("or", parts, EVERYTHING, NOTHING)
26
+
27
+ def folded(op, parts, absorbing, neutral)
28
+ return absorbing if parts.any? { it.equal?(absorbing) }
29
+
30
+ rest = parts.reject { it.equal?(neutral) }
31
+ return neutral if rest.empty?
32
+
33
+ rest.one? ? rest.first : { op => rest }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require "sequel/notion/filter_tables"
6
+ require "sequel/notion/type_map"
7
+
8
+ module Sequel
9
+ module Notion
10
+ # Small shared helpers mixed into FilterCompiler: property/type
11
+ # lookups (rule N), column/literal recognition (rule S), and value
12
+ # coercion. All private; they rely on the host's @prop_types.
13
+ module FilterHelpers
14
+ private
15
+
16
+ # ----------------------------------------------------------
17
+ # Shared lookups
18
+ # ----------------------------------------------------------
19
+
20
+ def lookup_type!(name)
21
+ raise page_column_error(name) if
22
+ TypeMap::PAGE_KEYS.include?(name)
23
+
24
+ @prop_types[name] or
25
+ raise Sequel::Error,
26
+ "Unknown property '#{name}'. " \
27
+ "Available: #{@prop_types.keys.join(", ")}"
28
+ end
29
+
30
+ # id and in_trash are the page's own, no property to filter on
31
+ def page_column_error(name)
32
+ hint = ": look pages up by id alone, where(id: ...)" if
33
+ name == "id"
34
+ Sequel::Error.new("'#{name}' is the page's own column, " \
35
+ "which Notion cannot filter on#{hint}")
36
+ end
37
+
38
+ def filter_key_for!(name, type)
39
+ FilterTables::FILTER_TYPE_KEY[type] or
40
+ raise Sequel::Error,
41
+ "Property '#{name}' (type: #{type}) is not " \
42
+ "filterable"
43
+ end
44
+
45
+ def ensure_supported!(name, key, op)
46
+ ops = FilterTables::SUPPORTED_OPS.fetch(key, [])
47
+ return if ops.include?(op)
48
+
49
+ why = " (never empty)" if key == "unique_id" && op =~ /empty/
50
+ raise Sequel::Error, "Operator '#{op}' not supported for " \
51
+ "#{key} property '#{name}'#{why}"
52
+ end
53
+
54
+ def equal_to_contains(op)
55
+ case op
56
+ when "equals" then "contains"
57
+ when "does_not_equal" then "does_not_contain"
58
+ else op
59
+ end
60
+ end
61
+
62
+ # ----------------------------------------------------------
63
+ # Column / literal helpers (rule S)
64
+ # ----------------------------------------------------------
65
+
66
+ def column_expr?(x)
67
+ case x
68
+ when Symbol, String, Sequel::SQL::Identifier,
69
+ Sequel::SQL::QualifiedIdentifier
70
+ true
71
+ else
72
+ false
73
+ end
74
+ end
75
+
76
+ def bare_column_expr?(x)
77
+ x.is_a?(Symbol) ||
78
+ x.is_a?(Sequel::SQL::Identifier) ||
79
+ x.is_a?(Sequel::SQL::QualifiedIdentifier)
80
+ end
81
+
82
+ # Sequel wraps a bare literal in a NOOP NumericExpression when it
83
+ # ends up as the receiver of a comparison operator inside a
84
+ # virtual row block (e.g. `5 < n`). Unwrap it back to the plain
85
+ # value.
86
+ def unwrap_literal(value)
87
+ if value.is_a?(Sequel::SQL::ComplexExpression) &&
88
+ value.op == :NOOP && value.args.length == 1
89
+ value.args.first
90
+ else
91
+ value
92
+ end
93
+ end
94
+
95
+ # ----------------------------------------------------------
96
+ # Value coercion
97
+ # ----------------------------------------------------------
98
+
99
+ def boolean_value!(value)
100
+ return value if [true, false].include?(value)
101
+
102
+ raise Sequel::Error, "not a boolean: #{value.inspect}"
103
+ end
104
+
105
+ # Integer and Float as is; any other number as a Float, which
106
+ # JSON writes as a number (BigDecimal and Rational would not).
107
+ # JSON has no NaN or Infinity.
108
+ def number_value!(value)
109
+ number = case value
110
+ when Integer, Float then value
111
+ when Numeric then value.to_f
112
+ when String then decimal_number!(value)
113
+ else Float(value)
114
+ end
115
+ return number if number.is_a?(Integer) || number.finite?
116
+
117
+ raise Sequel::Error, "not a number: #{value.inspect}"
118
+ rescue ArgumentError, TypeError, RangeError
119
+ raise Sequel::Error, "not a number: #{value.inspect}"
120
+ end
121
+
122
+ # Float() also accepts hex/binary/underscored strings; a
123
+ # number filter value is plain decimal text only
124
+ def decimal_number!(value)
125
+ raise Sequel::Error, "not a number: #{value.inspect}" unless
126
+ value.match?(TypeMap::DECIMAL)
127
+
128
+ Float(value)
129
+ end
130
+
131
+ # A unique id's number, given bare or as displayed ("TK-62")
132
+ def unique_id_value!(value)
133
+ return value if value.is_a?(Integer) && !value.negative?
134
+
135
+ number = value.to_s[/\A(?:[A-Za-z][\w-]*-)?(\d+)\z/, 1]
136
+ return number.to_i if value.is_a?(String) && number
137
+
138
+ raise Sequel::Error, "not a unique id: #{value.inspect}"
139
+ end
140
+
141
+ # A Date, Time or DateTime, or an ISO 8601 string
142
+ def date_value!(value)
143
+ return value.iso8601 if value.respond_to?(:iso8601)
144
+ return value if value.is_a?(String) &&
145
+ !Date._iso8601(value).empty?
146
+
147
+ raise Sequel::Error, "not a date: #{value.inspect}"
148
+ end
149
+
150
+ def coerce_value(value, key)
151
+ case key
152
+ when "date" then date_value!(value)
153
+ when "checkbox" then boolean_value!(value)
154
+ when "number" then number_value!(value)
155
+ when "unique_id" then unique_id_value!(value)
156
+ else
157
+ value.to_s
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/filter_tables"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # LIKE / ILIKE / NOT LIKE / NOT ILIKE compilation (rule L), mixed
8
+ # into FilterCompiler. All private.
9
+ #
10
+ # Notion does not distinguish case-sensitive from case-insensitive
11
+ # text matching, so LIKE and ILIKE (and their NOT variants) are
12
+ # treated identically here.
13
+ module FilterLike
14
+ # Carries the bits a LIKE-pattern error message needs, so the
15
+ # tokenizing/classifying helpers below stay under the parameter
16
+ # count limit.
17
+ LikeContext = Struct.new(:name, :sequel_op, :pattern)
18
+
19
+ private
20
+
21
+ def compile_like(expr)
22
+ left, pattern = expr.args
23
+ name = FilterCompiler.property_name(left)
24
+ validate_like_pattern!(pattern, name, expr.op)
25
+
26
+ type = lookup_type!(name)
27
+ key = filter_key_for!(name, type)
28
+
29
+ op, value = translate_like_pattern(pattern, name, expr.op)
30
+ op = like_operator(op, key, name, expr)
31
+ ensure_supported!(name, key, op)
32
+
33
+ { "property" => name, key => { op => value } }
34
+ end
35
+
36
+ # The pattern's operator, negated for NOT LIKE and turned into
37
+ # contains for the types that compare through it
38
+ def like_operator(op, key, name, expr)
39
+ op = whole_value_like!(op, expr) if contains_type?(key)
40
+ op = negate_like_operator(op, name) if negated_like?(expr.op)
41
+ contains_type?(key) ? equal_to_contains(op) : op
42
+ end
43
+
44
+ # Notion's contains on a multi_select, people or relation
45
+ # matches a whole option or id, never a substring: only a
46
+ # pattern without wildcard means the same thing
47
+ def whole_value_like!(op, expr)
48
+ return op if op == "equals"
49
+
50
+ left, pattern = expr.args
51
+ raise Sequel::Error,
52
+ "Unsupported #{expr.op} pattern for property " \
53
+ "'#{FilterCompiler.property_name(left)}' (matches " \
54
+ "whole values only, no wildcard): #{pattern.inspect}"
55
+ end
56
+
57
+ def validate_like_pattern!(pattern, name, sequel_op)
58
+ return if pattern.is_a?(String)
59
+
60
+ raise Sequel::Error,
61
+ "Unsupported #{sequel_op} pattern for property " \
62
+ "'#{name}': #{pattern.inspect}"
63
+ end
64
+
65
+ def negated_like?(sequel_op)
66
+ [:"NOT LIKE", :"NOT ILIKE"].include?(sequel_op)
67
+ end
68
+
69
+ def negate_like_operator(op, name)
70
+ case op
71
+ when "equals" then "does_not_equal"
72
+ when "contains" then "does_not_contain"
73
+ when "is_not_empty" then "is_empty"
74
+ when "starts_with", "ends_with"
75
+ raise Sequel::Error,
76
+ "Notion has no negated #{op} operator " \
77
+ "(property '#{name}')"
78
+ else op
79
+ end
80
+ end
81
+
82
+ # Unescapes \% \_ \\ as literals, then classifies the remaining
83
+ # (unescaped) wildcards into a Notion text operator.
84
+ def translate_like_pattern(pattern, name, sequel_op)
85
+ ctx = LikeContext.new(name, sequel_op, pattern)
86
+ tokens = tokenize_like_pattern(pattern)
87
+ reject_underscore_wildcard!(tokens, ctx)
88
+
89
+ literal = like_literal(tokens)
90
+ wild_idxs = like_wildcard_indexes(tokens)
91
+
92
+ classify_like_wildcards(wild_idxs, tokens.length - 1,
93
+ literal, ctx)
94
+ end
95
+
96
+ def reject_underscore_wildcard!(tokens, ctx)
97
+ return unless tokens.any? { |t| t[0] == :underscore }
98
+
99
+ raise Sequel::Error,
100
+ "Unsupported #{ctx.sequel_op} pattern for property " \
101
+ "'#{ctx.name}' (unescaped '_' is not supported): " \
102
+ "#{ctx.pattern.inspect}"
103
+ end
104
+
105
+ def like_literal(tokens)
106
+ tokens.select { it[0] == :lit }.map { it[1] }.join
107
+ end
108
+
109
+ def like_wildcard_indexes(tokens)
110
+ tokens.each_index.select { |idx| tokens[idx][0] == :wild }
111
+ end
112
+
113
+ def classify_like_wildcards(wild_idxs, last_idx, literal, ctx)
114
+ return ["is_not_empty", true] if
115
+ literal.empty? && !wild_idxs.empty?
116
+
117
+ case wild_idxs
118
+ when [] then ["equals", literal]
119
+ when [0] then classify_leading_wildcard(last_idx, literal, ctx)
120
+ when [last_idx] then ["starts_with", literal]
121
+ when [0, last_idx] then ["contains", literal]
122
+ else
123
+ raise unsupported_wildcard_error(ctx)
124
+ end
125
+ end
126
+
127
+ def classify_leading_wildcard(last_idx, literal, ctx)
128
+ # pattern was just "%"
129
+ raise unsupported_wildcard_error(ctx) if last_idx.zero?
130
+
131
+ ["ends_with", literal]
132
+ end
133
+
134
+ def unsupported_wildcard_error(ctx)
135
+ Sequel::Error.new(
136
+ "Unsupported #{ctx.sequel_op} pattern for property " \
137
+ "'#{ctx.name}' (unsupported wildcard placement): " \
138
+ "#{ctx.pattern.inspect}"
139
+ )
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # Tokenizes a SQL LIKE pattern into literal/wildcard pieces, mixed
6
+ # into FilterCompiler. All private. Unescapes \% \_ \\ as literal
7
+ # characters; unescaped % and _ become :wild / :underscore markers.
8
+ module FilterLikeTokenizer
9
+ ESCAPABLE_LIKE_CHARS = %w[% _ \\].freeze
10
+
11
+ private
12
+
13
+ def tokenize_like_pattern(pattern)
14
+ tokens = []
15
+ chars = pattern.chars
16
+ i = 0
17
+ while i < chars.length
18
+ token, consumed = next_like_token(chars, i)
19
+ tokens << token
20
+ i += consumed
21
+ end
22
+ tokens
23
+ end
24
+
25
+ def next_like_token(chars, i)
26
+ c = chars[i]
27
+ return [[:lit, chars[i + 1]], 2] if escaped_like_char?(chars, i)
28
+
29
+ case c
30
+ when "%" then [[:wild], 1]
31
+ when "_" then [[:underscore], 1]
32
+ else [[:lit, c], 1]
33
+ end
34
+ end
35
+
36
+ def escaped_like_char?(chars, i)
37
+ chars[i] == "\\" && i + 1 < chars.length &&
38
+ ESCAPABLE_LIKE_CHARS.include?(chars[i + 1])
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/filter_tables"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # Filter negation (rule G), mixed into FilterCompiler. All private.
8
+ # Negates a compiled Notion filter hash by swapping and/or and
9
+ # inverting the leaf operator via the explicit NEGATE_MAP table.
10
+ module FilterNegation
11
+ private
12
+
13
+ def negate(filter)
14
+ return opposite(filter) if constant?(filter)
15
+ if filter.key?("and")
16
+ return { "or" => negate_each(filter["and"]) }
17
+ end
18
+ if filter.key?("or")
19
+ return { "and" => negate_each(filter["or"]) }
20
+ end
21
+
22
+ negate_leaf(filter)
23
+ end
24
+
25
+ def negate_each(filters)
26
+ filters.map { negate(it) }
27
+ end
28
+
29
+ def negate_leaf(filter)
30
+ type_key = (filter.keys - ["property"]).first
31
+ inner = filter[type_key]
32
+
33
+ if %w[formula rollup].include?(type_key)
34
+ negate_nested_leaf(filter, type_key, inner)
35
+ else
36
+ negate_simple_leaf(filter, type_key, inner)
37
+ end
38
+ end
39
+
40
+ def negate_nested_leaf(filter, key, inner)
41
+ inner_key = inner.keys.first
42
+ cond = inner[inner_key]
43
+ op = cond.keys.first
44
+ if inner_key == "date" && op == "equals"
45
+ return nested_date_not_equal(filter["property"], key,
46
+ cond[op])
47
+ end
48
+
49
+ new_op = negated_operator!(op, filter["property"])
50
+ { "property" => filter["property"],
51
+ key => { inner_key => { new_op => cond[op] } } }
52
+ end
53
+
54
+ def negate_simple_leaf(filter, type_key, inner)
55
+ op = inner.keys.first
56
+ # Notion's date has no does_not_equal (rule D)
57
+ if type_key == "date" && op == "equals"
58
+ return date_not_equal_filter(filter["property"], inner[op])
59
+ end
60
+
61
+ new_op = negated_operator!(op, filter["property"])
62
+
63
+ { "property" => filter["property"],
64
+ type_key => { new_op => inner[op] } }
65
+ end
66
+
67
+ def negated_operator!(op, property)
68
+ FilterTables::NEGATE_MAP.fetch(op) do
69
+ raise Sequel::Error,
70
+ "Operator '#{op}' has no inverse " \
71
+ "(property '#{property}')"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # Formula (rule F) and rollup (rule R) comparisons, mixed into
8
+ # FilterCompiler: the condition nested under the kind of value
9
+ # the property gives. All private.
10
+ module FilterNested
11
+ NESTED_KEYS = %w[formula rollup].freeze
12
+
13
+ private
14
+
15
+ def compile_nested_key(name, op, value, key)
16
+ inner_key = if key == "formula"
17
+ formula_inner_key(value, name)
18
+ else
19
+ rollup_kind!(name)
20
+ end
21
+ compile_nested_comparison(name, op, value, key, inner_key)
22
+ end
23
+
24
+ # IN on a formula, a rollup or a date: an or of equalities, or
25
+ # an and of inequalities, each compiled as a comparison (nested,
26
+ # or a date's before-or-after)
27
+ def in_by_value(sequel_op, name, values, key)
28
+ op = sequel_op == :IN ? :"=" : :"!="
29
+ leaves = values.map do |v|
30
+ next null_check_filter(name, empty: op == :"=") if v.nil?
31
+
32
+ compile_typed_comparison(name, op, v, key)
33
+ end
34
+ { (sequel_op == :IN ? "or" : "and") => leaves }
35
+ end
36
+
37
+ # Notion checks a formula's emptiness under any value key,
38
+ # whatever its result type (checked live); a rollup's under
39
+ # the kind it gives
40
+ def nested_null_filter(name, key, op)
41
+ inner = key == "formula" ? "string" : rollup_kind!(name)
42
+ { "property" => name, key => { inner => { op => true } } }
43
+ end
44
+
45
+ # A formula's kind follows the value's class
46
+ def formula_inner_key(value, name)
47
+ case value
48
+ when String then "string"
49
+ when Numeric then "number"
50
+ when true, false then "checkbox"
51
+ when Date, Time, DateTime then "date"
52
+ else
53
+ raise Sequel::Error,
54
+ "Unsupported formula value type for property " \
55
+ "'#{name}': #{value.class}"
56
+ end
57
+ end
58
+
59
+ def compile_formula_comparison(name, op, value)
60
+ compile_nested_comparison(name, op, value, "formula",
61
+ formula_inner_key(value, name))
62
+ end
63
+
64
+ def compile_nested_comparison(name, op, value, key, inner_key)
65
+ coerced = coerce_value(value, inner_key)
66
+ if inner_key == "date" && op == :"!="
67
+ return nested_date_not_equal(name, key, coerced)
68
+ end
69
+
70
+ notion_op = translated_operator(op, inner_key)
71
+ ensure_supported!(name, inner_key, notion_op)
72
+ { "property" => name,
73
+ key => { inner_key => { notion_op => coerced } } }
74
+ end
75
+
76
+ # A rollup whose function gives one value; one that keeps
77
+ # every value is filtered by any/every/none in Notion, which a
78
+ # SQL comparison does not say
79
+ def rollup_kind!(name)
80
+ kind = @rollups[name]
81
+ return kind if %w[number date].include?(kind)
82
+
83
+ raise Sequel::Error,
84
+ "Rollup '#{name}' gives #{kind || "an unknown"} " \
85
+ "values; only number and date rollups filter"
86
+ end
87
+
88
+ # Notion's date has no does_not_equal (rule D)
89
+ def nested_date_not_equal(name, key, value)
90
+ { "or" => %w[before after].map do |op|
91
+ { "property" => name, key => { "date" => { op => value } } }
92
+ end }
93
+ end
94
+ end
95
+ end
96
+ end