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