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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # HAVING over the groups DatasetGrouping computes, evaluated in
6
+ # Ruby with SQL's three-valued logic: a comparison with NULL is
7
+ # unknown, and only a true condition keeps a group. An aggregate
8
+ # it writes out (count(*) > 1) is computed as a hidden output.
9
+ module DatasetHaving
10
+ COMPARE = { "=": :==, "!=": :!=, "<": :<, ">": :>, "<=": :<=,
11
+ ">=": :>= }.freeze
12
+
13
+ private
14
+
15
+ def having_functions(expr = @opts[:having], found = [])
16
+ case expr
17
+ when SQL::Function
18
+ found << expr unless found.include?(expr)
19
+ when SQL::ComplexExpression, Array
20
+ (expr.is_a?(Array) ? expr : expr.args)
21
+ .each { having_functions(it, found) }
22
+ end
23
+ found
24
+ end
25
+
26
+ def having_name(index) = :"__having_#{index}"
27
+
28
+ def having?(row, funcs)
29
+ having_value(@opts[:having], row, funcs) == true
30
+ end
31
+
32
+ def having_value(expr, row, funcs)
33
+ case expr
34
+ when SQL::Function then row[having_name(funcs.index(expr))]
35
+ when SQL::BooleanExpression
36
+ having_boolean(expr, row, funcs)
37
+ when Symbol, SQL::Identifier, SQL::QualifiedIdentifier
38
+ having_column(expr, row)
39
+ when Array then expr.map { having_value(it, row, funcs) }
40
+ else having_literal(expr, row, funcs)
41
+ end
42
+ end
43
+
44
+ def having_literal(expr, row, funcs)
45
+ return expr unless expr.is_a?(SQL::Expression)
46
+ if expr.is_a?(SQL::ComplexExpression) && expr.op == :NOOP
47
+ return having_value(expr.args.first, row, funcs)
48
+ end
49
+
50
+ raise Error, "Unsupported HAVING expression: #{expr.inspect}"
51
+ end
52
+
53
+ def having_column(expr, row)
54
+ name = group_column(expr)
55
+ return row[name] if row.key?(name)
56
+
57
+ raise Error,
58
+ "HAVING names #{name}, neither grouped nor selected"
59
+ end
60
+
61
+ def having_boolean(expr, row, funcs)
62
+ values = expr.args.map { having_value(it, row, funcs) }
63
+ case expr.op
64
+ when :AND, :OR, :NOT then logical(expr.op, values)
65
+ when :IS, :"IS NOT" then identity(expr.op, *values)
66
+ when :IN, :"NOT IN" then in_list(expr.op, *values)
67
+ else compared(expr.op, *values)
68
+ end
69
+ end
70
+
71
+ def logical(op, values)
72
+ return (values.first.nil? ? nil : !values.first) if op == :NOT
73
+
74
+ decisive = op != :AND
75
+ return decisive if values.include?(decisive)
76
+
77
+ values.include?(nil) ? nil : !decisive
78
+ end
79
+
80
+ def identity(op, left, right)
81
+ same = right.nil? ? left.nil? : left == right
82
+ op == :IS ? same : !same
83
+ end
84
+
85
+ def in_list(op, left, list)
86
+ found = list.include?(left) unless left.nil?
87
+ found = nil if !found && (left.nil? || list.include?(nil))
88
+ op == :IN || found.nil? ? found : !found
89
+ end
90
+
91
+ def compared(op, left, right)
92
+ method = COMPARE.fetch(op) do
93
+ raise Error, "Unsupported HAVING operator: #{op}"
94
+ end
95
+ left.nil? || right.nil? ? nil : left.public_send(method, right)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/join_output"
4
+ require "sequel/notion/join_where"
5
+ require "sequel/notion/registry"
6
+
7
+ module Sequel
8
+ module Notion
9
+ # INNER and LEFT joins on one equality, which Notion's API does
10
+ # not compute: each source is queried with the conditions that
11
+ # test it alone, then rows are matched in Ruby. A relation (an
12
+ # Array of page ids) matches every page it lists. Without a
13
+ # SELECT, a later table's column wins a shared name, as with SQL
14
+ # adapters.
15
+ module DatasetJoins
16
+ include JoinOutput
17
+ include JoinWhere
18
+
19
+ JOIN_TYPES = %i[inner left left_outer].freeze
20
+
21
+ private
22
+
23
+ def joined? = !@opts[:join].nil?
24
+
25
+ def join_rows(&)
26
+ sources = join_sources
27
+ rows = joined_rows(sources).map { join_output(it, sources) }
28
+ rows = sort_rows(rows).drop(@opts[:offset] || 0)
29
+ rows = rows.take(@opts[:limit]) if @opts[:limit]
30
+ rows.each(&)
31
+ end
32
+
33
+ # { source name => row }, one per match
34
+ def joined_rows(sources)
35
+ wheres = split_where(sources)
36
+ first = sources.first
37
+ rows = source_rows(first, wheres)
38
+ .map { { first.first => it } }
39
+ @opts[:join].each_with_index.reduce(rows) do |acc, (clause, i)|
40
+ joined(acc, clause, sources[i + 1], wheres)
41
+ end
42
+ end
43
+
44
+ def join_sources
45
+ [[source_table.to_s, source_table]] +
46
+ @opts[:join].map do |clause|
47
+ table = clause.table
48
+ table = table.value if table.is_a?(SQL::Identifier)
49
+ [(clause.table_alias || table).to_s, table.to_sym]
50
+ end
51
+ end
52
+
53
+ def source_rows((name, table), wheres)
54
+ ds = db[table]
55
+ Array(wheres[name]).each { ds = ds.where(it) }
56
+ ds.naked.all
57
+ end
58
+
59
+ def joined(rows, clause, (name, table), wheres)
60
+ left, right = join_columns(clause, name)
61
+ if clause.join_type != :inner && wheres[name]
62
+ return left_joined(rows, left, right, [name, table],
63
+ wheres)
64
+ end
65
+
66
+ index = join_index(source_rows([name, table], wheres), right)
67
+ rows.flat_map { matched(it, left, index, clause, name) }
68
+ end
69
+
70
+ def join_index(rows, right)
71
+ index = Hash.new { |h, k| h[k] = [] }
72
+ rows.each do |row|
73
+ join_keys(row[right]).each { index[it] << row }
74
+ end
75
+ index
76
+ end
77
+
78
+ def matched(row, (table, column), index, clause, name)
79
+ found = partners(row, table, column, index)
80
+ return found.map { row.merge(name => it) } unless found.empty?
81
+
82
+ clause.join_type == :inner ? [] : [row.merge(name => nil)]
83
+ end
84
+
85
+ def partners(row, table, column, index)
86
+ join_keys(row[table]&.[](column)).flat_map { index[it] }.uniq
87
+ end
88
+
89
+ # [[left source, column], right column] of an ON a = b
90
+ def join_columns(clause, name)
91
+ args = join_on!(clause).args
92
+ right, left = args.partition { it.table.to_s == name }
93
+ unless right.size == 1
94
+ raise Error, "a join's ON must test the joined table once"
95
+ end
96
+
97
+ [[left.first.table.to_s, left.first.column.to_sym],
98
+ right.first.column.to_sym]
99
+ end
100
+
101
+ def join_on!(clause)
102
+ on = clause.on if clause.is_a?(SQL::JoinOnClause)
103
+ return on if JOIN_TYPES.include?(clause.join_type) &&
104
+ equality?(on)
105
+
106
+ raise Error, "Unsupported join: #{clause.inspect}"
107
+ end
108
+
109
+ def equality?(on)
110
+ on.is_a?(SQL::BooleanExpression) && on.op == :"=" &&
111
+ on.args.all?(SQL::QualifiedIdentifier)
112
+ end
113
+
114
+ # A value's join keys: a relation's ids, page ids without
115
+ # dashes and case
116
+ def join_keys(value)
117
+ Array(value).map do |v|
118
+ uuid = v.is_a?(String) && v.match?(Registry::UUID)
119
+ uuid ? v.delete("-").downcase : v
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/errors"
4
+ require "sequel/notion/filter_compiler"
5
+ require "sequel/notion/sort_compiler"
6
+
7
+ module Sequel
8
+ module Notion
9
+ # Where a dataset's pages come from: a paginated data source
10
+ # query, or a GET per page when WHERE pins the page id.
11
+ module DatasetPages
12
+ # Clauses Notion cannot express; refused rather than ignored
13
+ UNSUPPORTED = %i[join group having compounds lock].freeze
14
+
15
+ PAGE_SIZE = 100
16
+
17
+ private
18
+
19
+ def page_ids
20
+ ids = []
21
+ each_notion_page { ids << it["id"] }
22
+ ids
23
+ end
24
+
25
+ # Yield raw pages, after OFFSET and up to LIMIT
26
+ def each_notion_page
27
+ check_supported!
28
+ skip = @opts[:offset] || 0
29
+ max = @opts[:limit]
30
+ seen = 0
31
+ source_pages do |page|
32
+ next if (skip -= 1) >= 0
33
+
34
+ yield page
35
+ break if max && (seen += 1) >= max
36
+ end
37
+ end
38
+
39
+ def check_supported!
40
+ bad = UNSUPPORTED.select { @opts[it] }
41
+ return if bad.empty?
42
+
43
+ raise Error, "Notion datasets do not support: " \
44
+ "#{bad.join(", ")}"
45
+ end
46
+
47
+ def source_pages(&)
48
+ ds_id = data_source_id
49
+ ids, rest = split_id_condition(@opts[:where])
50
+ return pages_by_id(ds_id, ids, rest, &) if ids
51
+
52
+ filter = @opts[:where] &&
53
+ FilterCompiler.for(db, ds_id).compile(@opts[:where])
54
+ query_pages(ds_id, filter, &) unless
55
+ filter.equal?(FilterCompiler::NOTHING)
56
+ end
57
+
58
+ def query_pages(ds_id, filter)
59
+ cursor = nil
60
+ loop do
61
+ resp = db.notion_query(ds_id, query_body(filter, cursor))
62
+ (resp["results"] || []).each { yield it }
63
+ break unless resp["has_more"]
64
+
65
+ cursor = resp["next_cursor"]
66
+ end
67
+ end
68
+
69
+ def query_body(filter, cursor)
70
+ body = { page_size: notion_page_size }
71
+ body[:filter] = filter if filter
72
+ body[:sorts] = SortCompiler.compile(@opts[:order]) if
73
+ @opts[:order]
74
+ body[:start_cursor] = cursor if cursor
75
+ body
76
+ end
77
+
78
+ def notion_page_size
79
+ offset = @opts[:offset] || 0
80
+ want = @opts[:limit] && (@opts[:limit] + offset)
81
+ [want || PAGE_SIZE, @opts[:notion_page_size] || PAGE_SIZE,
82
+ PAGE_SIZE].min
83
+ end
84
+
85
+ # ----------------------------------------------------------
86
+ # Lookup by page id
87
+ # ----------------------------------------------------------
88
+
89
+ # [ids, other conditions] when WHERE pins the id, else nil
90
+ def split_id_condition(where)
91
+ return unless where.is_a?(SQL::BooleanExpression)
92
+ return id_and_rest(where.args) if where.op == :AND
93
+
94
+ ids = id_values(where)
95
+ [ids, []] if ids
96
+ end
97
+
98
+ def id_and_rest(conds)
99
+ sets = conds.filter_map { id_values(it) }
100
+ return if sets.empty?
101
+
102
+ [sets.reduce(:&), conds.reject { id_values(it) }]
103
+ end
104
+
105
+ def id_values(expr)
106
+ return unless expr.is_a?(SQL::BooleanExpression)
107
+ return unless %i[= IN].include?(expr.op)
108
+ return unless FilterCompiler.property_name(expr.args[0]) ==
109
+ "id"
110
+
111
+ Array(expr.args[1]).map { bare_id(it) }.uniq
112
+ rescue Error
113
+ nil
114
+ end
115
+
116
+ def pages_by_id(ds_id, ids, rest)
117
+ unless rest.empty?
118
+ raise Error, "an id lookup cannot be combined with " \
119
+ "other conditions"
120
+ end
121
+
122
+ pages = ids.lazy.filter_map { fetch_page(it) }
123
+ .select { in_source?(it, ds_id) }
124
+ pages = ordered_pages(pages.to_a) if @opts[:order]
125
+ pages.each { yield it }
126
+ end
127
+
128
+ # Trashed pages included: the row says so, and this is the
129
+ # only way to reach one to restore it
130
+ def fetch_page(id)
131
+ db.notion_get_page(id)
132
+ rescue NotFoundError
133
+ nil
134
+ end
135
+
136
+ def in_source?(page, ds_id)
137
+ parent = page["parent"] || {}
138
+ parent["type"] == "data_source_id" &&
139
+ bare_id(parent["data_source_id"]) == bare_id(ds_id)
140
+ end
141
+
142
+ def bare_id(id) = id.to_s.delete("-").downcase
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/notion/filter_compiler"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # SELECT for Notion datasets: plain columns, optionally aliased
8
+ module DatasetSelection
9
+ private
10
+
11
+ # [[source column, output name], ...], or nil for every column
12
+ def selection
13
+ sel = @opts[:select]
14
+ return if sel.nil? || sel.empty?
15
+ return if sel.any? { it == :* || it.is_a?(SQL::ColumnAll) }
16
+
17
+ known!(sel.map { selected(it) })
18
+ end
19
+
20
+ def known!(pairs)
21
+ unknown = pairs.map(&:first) -
22
+ db.schema(source_table).map(&:first)
23
+ return pairs if unknown.empty?
24
+
25
+ raise Error, "Unknown columns in select: #{unknown.join(", ")}"
26
+ end
27
+
28
+ def selected(expr)
29
+ if expr.is_a?(SQL::AliasedExpression)
30
+ [column_name(expr.expression), expr.alias.to_sym]
31
+ else
32
+ name = column_name(expr)
33
+ [name, name]
34
+ end
35
+ end
36
+
37
+ def column_name(expr)
38
+ FilterCompiler.property_name(expr).to_sym
39
+ rescue Error
40
+ raise Error, "Unsupported select expression: " \
41
+ "#{expr.inspect} (only columns can be selected)"
42
+ end
43
+
44
+ def project(row, sel)
45
+ return row unless sel
46
+
47
+ sel.to_h { |source, name| [name, row[source]] }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # A page lists at most 25 relations or people; the rest is read
6
+ # from the page property endpoint, for the properties a row
7
+ # returns. A relation says has_more; people say nothing, so 25
8
+ # of them may be more.
9
+ module DatasetTruncation
10
+ LISTED = 25
11
+
12
+ private
13
+
14
+ def complete_page(page, sel)
15
+ wanted = sel&.map { it.first.to_s }
16
+ (page["properties"] || {}).each do |name, prop|
17
+ next if wanted && !wanted.include?(name)
18
+
19
+ complete_property(page["id"], prop) if truncated?(prop)
20
+ end
21
+ page
22
+ end
23
+
24
+ def complete_property(page_id, prop)
25
+ type = prop["type"]
26
+ prop[type] = db.notion_property_items(page_id, prop["id"])
27
+ .map { it[type] }
28
+ end
29
+
30
+ def truncated?(prop)
31
+ case prop["type"]
32
+ when "relation" then prop["has_more"] == true
33
+ when "people" then Array(prop["people"]).size >= LISTED
34
+ else false
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # Listing the data sources the integration can see
6
+ module Discovery
7
+ # List data sources: those of one database, or a search
8
+ def data_sources(database: nil, query: nil)
9
+ if database && query
10
+ raise ArgumentError, "database and query are exclusive"
11
+ end
12
+
13
+ database ? database_sources(database) : search_sources(query)
14
+ end
15
+
16
+ private
17
+
18
+ # Notion's search still lists a trashed data source, flagged
19
+ def live(sources) = sources.reject { it[:in_trash] }
20
+
21
+ # Ids of the live data sources whose title normalises to name
22
+ def matching_source_ids(name)
23
+ wanted = Registry.normalize(name)
24
+ return [] if wanted.empty?
25
+
26
+ live(search_sources(name.to_s.tr("_", " ")))
27
+ .select { Registry.normalize(it[:name]) == wanted }
28
+ .map { it[:id] }.uniq
29
+ end
30
+
31
+ def database_sources(database)
32
+ resp = request(:get, "databases/#{database}")
33
+ (resp["data_sources"] || []).map do |ds|
34
+ ds.slice("id", "name").transform_keys(&:to_sym)
35
+ .merge(parent_database_id: database)
36
+ end
37
+ end
38
+
39
+ def search_sources(query)
40
+ sources = []
41
+ cursor = nil
42
+ loop do
43
+ resp = request(:post, "search", search_body(query, cursor))
44
+ sources.concat(source_entries(resp["results"]))
45
+ break unless resp["has_more"]
46
+
47
+ cursor = resp["next_cursor"]
48
+ end
49
+ sources
50
+ end
51
+
52
+ def source_entries(results)
53
+ (results || []).filter_map do |entry|
54
+ source_entry(entry) if entry["object"] == "data_source"
55
+ end
56
+ end
57
+
58
+ def search_body(query, cursor)
59
+ body = { page_size: 100,
60
+ filter: { property: "object", value: "data_source" },
61
+ sort: { direction: "descending",
62
+ timestamp: "last_edited_time" } }
63
+ body[:query] = query if query
64
+ body[:start_cursor] = cursor if cursor
65
+ body
66
+ end
67
+
68
+ def source_entry(ds)
69
+ ds.slice("id", "icon", "url", "in_trash", "properties")
70
+ .transform_keys(&:to_sym)
71
+ .merge(parent_database_id: ds.dig("parent", "database_id"),
72
+ name: ds["title"]&.map { it["plain_text"] }&.join)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Notion
5
+ # A Notion object (page, data source) that does not exist, or that
6
+ # the integration cannot see
7
+ class NotFoundError < Sequel::DatabaseError; end
8
+ end
9
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "sequel/notion/filter_tables"
5
+
6
+ module Sequel
7
+ module Notion
8
+ # Standard and date-inequality (rule D) comparison compilation,
9
+ # mixed into FilterCompiler; formulas and rollups are FilterNested's.
10
+ # All private.
11
+ module FilterComparison
12
+ private
13
+
14
+ # ----------------------------------------------------------
15
+ # Standard comparison
16
+ # ----------------------------------------------------------
17
+
18
+ def compile_comparison(expr)
19
+ case expr.op
20
+ when :IN, :"NOT IN" then return compile_in(expr)
21
+ when :LIKE, :ILIKE, :"NOT LIKE", :"NOT ILIKE"
22
+ return compile_like(expr)
23
+ end
24
+
25
+ name, op, value = comparison_operands(expr)
26
+ return null_comparison_filter(name, op) if value.nil?
27
+
28
+ type = lookup_type!(name)
29
+ key = filter_key_for!(name, type)
30
+
31
+ compile_typed_comparison(name, op, value, key)
32
+ end
33
+
34
+ # Swaps a comparison's operands into column-then-value order
35
+ # (rule S): a < b == b > a
36
+ def comparison_operands(expr)
37
+ op = expr.op
38
+ left, right = expr.args
39
+
40
+ if !column_expr?(left) && column_expr?(right)
41
+ left, right = right, left
42
+ op = mirrored_operator(op)
43
+ end
44
+
45
+ [FilterCompiler.property_name(left), op,
46
+ literal_value!(unwrap_literal(right))]
47
+ end
48
+
49
+ # Notion compares a property with a value, never with another
50
+ # property or an expression
51
+ def literal_value!(value)
52
+ return value unless bare_column_expr?(value) ||
53
+ value.is_a?(Sequel::SQL::Expression)
54
+
55
+ raise Sequel::Error,
56
+ "Notion filters compare with values only: " \
57
+ "#{value.inspect}"
58
+ end
59
+
60
+ def mirrored_operator(op)
61
+ FilterTables::MIRROR_OPERATOR.fetch(op) do
62
+ raise Sequel::Error, "Unsupported operator: #{op}"
63
+ end
64
+ end
65
+
66
+ def null_comparison_filter(name, op)
67
+ case op
68
+ when :"=" then null_check_filter(name, empty: true)
69
+ when :"!=" then null_check_filter(name, empty: false)
70
+ else
71
+ raise Sequel::Error,
72
+ "Unsupported nil comparison operator: #{op}"
73
+ end
74
+ end
75
+
76
+ def compile_typed_comparison(name, op, value, key)
77
+ if FilterNested::NESTED_KEYS.include?(key)
78
+ return compile_nested_key(name, op, value, key)
79
+ end
80
+ if key == "date" && op == :"!="
81
+ return date_not_equal_filter(name, value)
82
+ end
83
+
84
+ notion_op = translated_operator(op, key)
85
+ ensure_supported!(name, key, notion_op)
86
+
87
+ { "property" => name,
88
+ key => { notion_op => coerce_value(value, key) } }
89
+ end
90
+
91
+ # Looks up the Notion operator for `op`, remapping it for date
92
+ # properties and for the types that express (not)equal through
93
+ # contains/does_not_contain (rule IN).
94
+ def translated_operator(op, key)
95
+ notion_op = FilterTables::OPERATOR_MAP.fetch(op) do
96
+ raise Sequel::Error, "Unsupported operator: #{op}"
97
+ end
98
+ if key == "date"
99
+ notion_op = FilterTables::DATE_OPERATOR_MAP.fetch(
100
+ notion_op, notion_op
101
+ )
102
+ end
103
+ contains_type?(key) ? equal_to_contains(notion_op) : notion_op
104
+ end
105
+
106
+ # ----------------------------------------------------------
107
+ # Date inequality (rule D)
108
+ # ----------------------------------------------------------
109
+
110
+ def date_not_equal_filter(name, value)
111
+ coerced = coerce_value(value, "date")
112
+ {
113
+ "or" => [
114
+ { "property" => name,
115
+ "date" => { "before" => coerced } },
116
+ { "property" => name,
117
+ "date" => { "after" => coerced } }
118
+ ]
119
+ }
120
+ end
121
+ end
122
+ end
123
+ end