lowdata 0.1.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/lib/col.rb +59 -0
- data/lib/data_expression.rb +136 -0
- data/lib/errors.rb +11 -0
- data/lib/filter_compiler.rb +47 -0
- data/lib/inversion.rb +39 -0
- data/lib/lowdata.rb +90 -0
- data/lib/nesting.rb +100 -0
- data/lib/nesting_tree.rb +145 -0
- data/lib/order_compiler.rb +42 -0
- data/lib/query_compiler.rb +107 -0
- data/lib/rows.rb +55 -0
- data/lib/schema.rb +54 -0
- data/lib/table_reference.rb +102 -0
- data/lib/version.rb +5 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c2e8629cffb84f30179cf393797a47d5cb05f7f03091a2b2cc122d8053593a4
|
|
4
|
+
data.tar.gz: 1468121aa2d7c393a167f890ad009da216191ed2da0d83371ce33e05f3cc4100
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fabdfb8df5462fd1274a435bcc4a1230f58cafb17f0ebc6ec03a4652b81b86f61ef877d4237c121510325d0a72280a3de0afcf81490aea61facd1dc6336786c9
|
|
7
|
+
data.tar.gz: 9311a3e886512bf5eb297690db47fb78ff5f81c9d9c267ab1a5b9a289de94cc784909a4b8bf6104ab6841b56cd9c2e4be8f3a13f2bc14f296448389d3f03cc07
|
data/lib/col.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Columns to sort by with ^, and optionally which of the sorted rows to keep:
|
|
5
|
+
# Posts[:title] ^ Col[:views, :title] # ORDER BY views, title
|
|
6
|
+
# Posts[:title] ^ -Col[:views] # ORDER BY views DESC
|
|
7
|
+
# Posts[:title] ^ -Col[:views] + Col[:title] # ORDER BY views DESC, title
|
|
8
|
+
# Posts[:title] ^ -Col[:views] * 10 # ... LIMIT 10
|
|
9
|
+
# Posts[:title] ^ -Col[:views][20...40] # ... LIMIT 20 OFFSET 20
|
|
10
|
+
# (Posts[:title] ^ -Col[:views] / 20)[2] # ... LIMIT 20 OFFSET 40
|
|
11
|
+
# Ruby binds -, +, *, / and [] tighter than ^, so the rows travel with the sort keys to the expression.
|
|
12
|
+
class Col
|
|
13
|
+
Key = ::Data.define(:column, :descending)
|
|
14
|
+
|
|
15
|
+
attr_reader :keys, :rows
|
|
16
|
+
|
|
17
|
+
def self.[](*columns)
|
|
18
|
+
invalid = columns.reject { |column| column.is_a?(Symbol) }
|
|
19
|
+
raise ArgumentError, "Col[] takes column names, e.g. Col[:views], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
|
|
20
|
+
raise ArgumentError, 'Col[] takes at least one column, e.g. Col[:views]' if columns.empty?
|
|
21
|
+
|
|
22
|
+
new(keys: columns.map { |column| Key.new(column:, descending: false) })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(keys:, rows: nil)
|
|
26
|
+
@keys = keys
|
|
27
|
+
@rows = rows
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def -@
|
|
32
|
+
with(keys: keys.map { |key| key.with(descending: !key.descending) })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def +(other)
|
|
36
|
+
raise ArgumentError, "Add sort columns with Col[], e.g. -Col[:views] + Col[:title], not #{other.inspect}" unless other.is_a?(Col)
|
|
37
|
+
|
|
38
|
+
with(keys: keys + other.keys, rows: Rows.merge(rows, other.rows))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def *(count)
|
|
42
|
+
with(rows: Rows.merge(rows, Rows.limit(count)))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def /(size)
|
|
46
|
+
with(rows: Rows.merge(rows, Rows.pages(size)))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def [](range)
|
|
50
|
+
with(rows: Rows.merge(rows, Rows.range(range)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def with(keys: @keys, rows: @rows)
|
|
56
|
+
self.class.new(keys:, rows:)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Joins tables with operators and builds a join tree for QueryCompiler:
|
|
5
|
+
# Users[:username, Posts[:title]] # Nested; every user once, with their posts nested. See Nesting.
|
|
6
|
+
# Users[:username] + Posts[:title] # Nested, shorthand for the above.
|
|
7
|
+
# Users[:username] & Posts[:title] # Inner join.
|
|
8
|
+
# Users[:username] < Posts[:title] # Left join.
|
|
9
|
+
# Users[:username] > Posts[:title] # Right join.
|
|
10
|
+
# Users[:username] <=> Posts[:title] # Full outer join.
|
|
11
|
+
# Table[:username, :title] # Inverted; the repository's table joined to whichever tables own the columns.
|
|
12
|
+
# And sorts and limits the result. See Col and Rows:
|
|
13
|
+
# Posts[:title] ^ -Col[:views] * 10 # ORDER BY views DESC LIMIT 10
|
|
14
|
+
# (Posts[:title] / 20)[2] # LIMIT 20 OFFSET 40
|
|
15
|
+
class DataExpression < ::Expressions::Expression
|
|
16
|
+
# The filters in a table's round brackets: `Posts(published: true) { views > 100 }`. See FilterCompiler.
|
|
17
|
+
Filter = ::Data.define(:values, :conditions, :related) do
|
|
18
|
+
def initialize(values: {}, conditions: [], related: [])
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def merge(other)
|
|
23
|
+
with(values: values.merge(other.values), conditions: conditions + other.conditions, related: related + other.related)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
# A table in another table's round brackets, keeping rows with `count` related rows: `Users(Posts => 3..)`.
|
|
27
|
+
Related = ::Data.define(:key, :source, :count)
|
|
28
|
+
# A table with the columns to select, the tables nested in its brackets and the filter on its rows.
|
|
29
|
+
Source = ::Data.define(:table, :columns, :nested, :filter) do
|
|
30
|
+
def initialize(table:, columns:, nested: [], filter: Filter.new)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
# A table nested in another table's brackets, under an optional key: `Reviews[:rating, author: Users[:username]]`.
|
|
35
|
+
Nested = ::Data.define(:key, :node)
|
|
36
|
+
Join = ::Data.define(:type, :left, :right)
|
|
37
|
+
Inversion = ::Data.define(:table, :columns, :filter)
|
|
38
|
+
# A node sorted by `keys` (Col::Key) with `rows` (Rows) kept: `Posts[:title] ^ -Col[:views] * 10`.
|
|
39
|
+
Ordered = ::Data.define(:node, :keys, :rows)
|
|
40
|
+
|
|
41
|
+
JOIN_HOOKS = { add: :nest, intersect: :inner, lt: :left, gt: :right, compare: :full }.freeze
|
|
42
|
+
|
|
43
|
+
attr_reader :node
|
|
44
|
+
|
|
45
|
+
def initialize(node:)
|
|
46
|
+
@node = node
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
SORT_MESSAGE = 'Sort by columns with Col[], e.g. Posts[:title] ^ Col[:views], not %<value>s'
|
|
50
|
+
NESTED_SQL_MESSAGE = '+ loads nested data with one query per table, so it has no single SQL query. Call to_a instead'
|
|
51
|
+
|
|
52
|
+
def dataset(database: LowData.database)
|
|
53
|
+
raise Error, NESTED_SQL_MESSAGE if nested?
|
|
54
|
+
|
|
55
|
+
QueryCompiler.new(database:).compile(node: @node)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sql(database: LowData.database)
|
|
59
|
+
dataset(database:).sql
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_a(database: LowData.database)
|
|
63
|
+
return Nesting.new(database:).load(node: @node) if nested?
|
|
64
|
+
|
|
65
|
+
dataset(database:).all
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Picks a page after splitting rows into pages: `(Posts[:title] / 20)[2]` is the third page of 20. Returns a new
|
|
69
|
+
# expression, so the same pages can be picked from more than once.
|
|
70
|
+
def [](page)
|
|
71
|
+
ordered = @node if @node.is_a?(Ordered)
|
|
72
|
+
raise ArgumentError, 'Split rows into pages before picking one, e.g. (Posts[:title] / 20)[0]' unless ordered&.rows
|
|
73
|
+
|
|
74
|
+
DataExpression.new(node: ordered.with(rows: ordered.rows.page(page)))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def nested?
|
|
80
|
+
node = @node.is_a?(Ordered) ? @node.node : @node
|
|
81
|
+
(node.is_a?(Join) && node.type == :nest) || (node.is_a?(Source) && node.nested.any?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Sorting again adds sort keys after the existing ones, and an expression keeps one limit, range or page size.
|
|
85
|
+
def order(keys:, rows:)
|
|
86
|
+
ordered = @node.is_a?(Ordered) ? @node : Ordered.new(node: @node, keys: [], rows: nil)
|
|
87
|
+
@node = ordered.with(keys: ordered.keys + keys, rows: Rows.merge(ordered.rows, rows))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def xor_value(value)
|
|
91
|
+
raise ArgumentError, format(SORT_MESSAGE, value: value.inspect) unless value.is_a?(Col)
|
|
92
|
+
|
|
93
|
+
order(keys: value.keys, rows: value.rows)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def multiply_value(count)
|
|
97
|
+
order(keys: [], rows: Rows.limit(count))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def divide_value(size)
|
|
101
|
+
order(keys: [], rows: Rows.pages(size))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def xor_expression(_expression)
|
|
105
|
+
raise ArgumentError, format(SORT_MESSAGE, value: 'a data expression')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def xor_type(type_)
|
|
109
|
+
raise ArgumentError, format(SORT_MESSAGE, value: type_)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{ multiply: 'Limit', divide: 'Split into pages' }.each do |prefix, action|
|
|
113
|
+
define_method(:"#{prefix}_expression") do |_expression|
|
|
114
|
+
raise ArgumentError, "#{action} by a positive integer, not a data expression"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
define_method(:"#{prefix}_type") do |type_|
|
|
118
|
+
raise ArgumentError, "#{action} by a positive integer, not the type #{type_}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
JOIN_HOOKS.each do |prefix, type|
|
|
123
|
+
define_method(:"#{prefix}_expression") do |expression|
|
|
124
|
+
@node = Join.new(type:, left: @node, right: expression.node)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
define_method(:"#{prefix}_value") do |value|
|
|
128
|
+
raise ArgumentError, "Join a data expression such as Posts[:title], not the value #{value.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
define_method(:"#{prefix}_type") do |type_|
|
|
132
|
+
raise ArgumentError, "Join a data expression such as Posts[:title], not the type #{type_}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/errors.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigError < Error
|
|
7
|
+
MISSING_DATABASE = 'Set LowData.database to a Sequel database, e.g. LowData.database = Sequel.sqlite'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class JoinError < Error; end
|
|
11
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Compiles a table's filter into WHERE conditions, each qualified with the table:
|
|
5
|
+
# Posts(published: true) # WHERE posts.published IS TRUE
|
|
6
|
+
# Posts { views > 100 } # WHERE posts.views > 100
|
|
7
|
+
# Users(Posts) # WHERE EXISTS (SELECT 1 FROM posts WHERE posts.user_id = users.id)
|
|
8
|
+
# Users(Posts => 3..) # WHERE (SELECT count(*) FROM posts WHERE posts.user_id = users.id) >= 3
|
|
9
|
+
class FilterCompiler
|
|
10
|
+
def initialize(database:, schema:)
|
|
11
|
+
@database = database
|
|
12
|
+
@links = NestingTree.new(schema:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def apply(dataset:, source:)
|
|
16
|
+
conditions(source:).reduce(dataset) { |filtered, condition| filtered.where(condition) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def conditions(source:)
|
|
22
|
+
table = source.table
|
|
23
|
+
filter = source.filter
|
|
24
|
+
values = filter.values.map { |column, value| { Sequel[table][column] => value } }
|
|
25
|
+
conditions = filter.conditions.map { |condition| Sequel::Qualifier.new(table).transform(condition) }
|
|
26
|
+
|
|
27
|
+
values + conditions + filter.related.map { |related| related_condition(table:, related:) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Correlates the related table's rows with the outer table's by the foreign key that links them, as nesting does.
|
|
31
|
+
def related_condition(table:, related:)
|
|
32
|
+
inner = related.source.table
|
|
33
|
+
raise JoinError, "Can't filter #{table} by its own rows" if inner == table
|
|
34
|
+
|
|
35
|
+
_key, link = @links.link_to(parent: table, child: inner, key: related.key)
|
|
36
|
+
rows = @database[inner].where(Sequel[inner][link.child_column] => Sequel[table][link.parent_column])
|
|
37
|
+
count(rows: apply(dataset: rows, source: related.source), count: related.count)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def count(rows:, count:)
|
|
41
|
+
return rows.select(1).exists if count == (1..)
|
|
42
|
+
return ~rows.select(1).exists if [0, 0..0].include?(count)
|
|
43
|
+
|
|
44
|
+
{ rows.select(Sequel.function(:count).*) => count }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/inversion.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Resolves `Table[:username, :title]` in PostsData to a join tree: posts left joined to each related table that owns a column.
|
|
5
|
+
class Inversion
|
|
6
|
+
def initialize(schema:)
|
|
7
|
+
@schema = schema
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Returns the join tree and the columns to select, in the order they were asked for.
|
|
11
|
+
def resolve(node:)
|
|
12
|
+
owners = node.columns.to_h { |column| [column, owner(table: node.table, column:)] }
|
|
13
|
+
base = DataExpression::Source.new(table: node.table, columns: [], filter: node.filter)
|
|
14
|
+
|
|
15
|
+
tree = (owners.values.uniq - [node.table]).reduce(base) do |left, table|
|
|
16
|
+
DataExpression::Join.new(type: :left, left:, right: DataExpression::Source.new(table:, columns: []))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
[tree, owners.map { |column, table| Sequel[table][column] }]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def owner(table:, column:)
|
|
25
|
+
return table if @schema.columns(table:).include?(column)
|
|
26
|
+
|
|
27
|
+
owners = @schema.related_tables(table:).select { |related| @schema.columns(table: related).include?(column) }
|
|
28
|
+
raise JoinError, "No table related to #{table} has a #{column} column" if owners.empty?
|
|
29
|
+
raise JoinError, ambiguous_message(table:, column:, owners:) if owners.length > 1
|
|
30
|
+
|
|
31
|
+
owners.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ambiguous_message(table:, column:, owners:)
|
|
35
|
+
"#{owners.join(' and ')} both have a #{column} column and are related to #{table}. " \
|
|
36
|
+
"Join explicitly instead, e.g. #{LowData.constant_name(table)}[] < #{LowData.constant_name(owners.first)}[:#{column}]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/lowdata.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'expressions'
|
|
4
|
+
require 'lowtype'
|
|
5
|
+
require 'sequel'
|
|
6
|
+
|
|
7
|
+
require_relative 'version'
|
|
8
|
+
require_relative 'errors'
|
|
9
|
+
require_relative 'data_expression'
|
|
10
|
+
require_relative 'rows'
|
|
11
|
+
require_relative 'col'
|
|
12
|
+
require_relative 'order_compiler'
|
|
13
|
+
require_relative 'filter_compiler'
|
|
14
|
+
require_relative 'schema'
|
|
15
|
+
require_relative 'inversion'
|
|
16
|
+
require_relative 'nesting'
|
|
17
|
+
require_relative 'nesting_tree'
|
|
18
|
+
require_relative 'query_compiler'
|
|
19
|
+
require_relative 'table_reference'
|
|
20
|
+
|
|
21
|
+
class LowData
|
|
22
|
+
class << self
|
|
23
|
+
attr_reader :database
|
|
24
|
+
attr_writer :table
|
|
25
|
+
|
|
26
|
+
def database=(database)
|
|
27
|
+
@database = database
|
|
28
|
+
@tables = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The database's tables, cached until the database changes.
|
|
32
|
+
def tables
|
|
33
|
+
@tables ||= database.tables
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The repository's table, named after the class: PostsData is :posts and BlogPostsData is :blog_posts.
|
|
37
|
+
def table
|
|
38
|
+
@table ||= name && table_name(name.split('::').last.delete_suffix('Data'))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve undefined constants in a repository to tables, so `Users` and `BlogPosts` become :users and :blog_posts.
|
|
42
|
+
# `Table` is the repository's own table, joined to whichever related tables own the columns asked for.
|
|
43
|
+
def const_missing(name)
|
|
44
|
+
return super if self == LowData
|
|
45
|
+
return inverted_table if name == :Table
|
|
46
|
+
|
|
47
|
+
TableReference.new(table: table_name(name))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The constant a table is written as in a data expression: :blog_posts is BlogPosts.
|
|
51
|
+
def constant_name(table)
|
|
52
|
+
table.to_s.split('_').map(&:capitalize).join
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def table_name(constant_name)
|
|
56
|
+
constant_name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def inverted_table
|
|
62
|
+
raise ConfigError, "Set the table for #{self}, e.g. self.table = :posts" if table.nil?
|
|
63
|
+
|
|
64
|
+
TableReference.new(table:, inverted: true)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Round brackets after a table filter it: `Posts(published: true) { views > 100 }[:title]`. A capitalised name is a
|
|
69
|
+
# table only when the database has that table, so typos still raise NoMethodError. `Table(...)` is always a table.
|
|
70
|
+
def method_missing(name, *arguments, **keywords, &block)
|
|
71
|
+
raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil? && name.match?(/\A[A-Z]/)
|
|
72
|
+
return super unless table_method?(name:)
|
|
73
|
+
|
|
74
|
+
self.class.const_missing(name).where(*arguments, **keywords, &block)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def respond_to_missing?(name, include_private = false)
|
|
78
|
+
table_method?(name:) || super
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def table_method?(name:)
|
|
84
|
+
return false unless name.match?(/\A[A-Z]/)
|
|
85
|
+
return true if name == :Table
|
|
86
|
+
return false if LowData.database.nil?
|
|
87
|
+
|
|
88
|
+
LowData.tables.include?(LowData.table_name(name))
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/nesting.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Loads nested data, one query per table. Tables nest explicitly in brackets, or by inference with +:
|
|
5
|
+
# Posts[:title, Users[:username]] # => [{ title: 'Hello', user: { username: 'alice' } }]
|
|
6
|
+
# Users[:username] + Posts[:title] # => [{ username: 'alice', posts: [{ title: 'Hello' }] }]
|
|
7
|
+
# In a + chain the first table is the subject, and each other operand nests under the table its foreign key links it to.
|
|
8
|
+
class Nesting
|
|
9
|
+
# A table in the nesting tree. `link` connects it to its parent node: `parent_column` on the parent's rows matches
|
|
10
|
+
# `child_column` on this table's rows, and `many` nests the matches as an array rather than one object.
|
|
11
|
+
# `keys` and `rows` sort and limit the table's rows, per parent when it's nested.
|
|
12
|
+
Node = ::Data.define(:source, :key, :link, :children, :keys, :rows) do
|
|
13
|
+
def initialize(source:, key:, link:, children:, keys: [], rows: nil)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
Link = ::Data.define(:parent_column, :child_column, :many)
|
|
18
|
+
|
|
19
|
+
ROW_NUMBER = :lowdata_row_number
|
|
20
|
+
|
|
21
|
+
def initialize(database:)
|
|
22
|
+
raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
|
|
23
|
+
|
|
24
|
+
@database = database
|
|
25
|
+
@schema = Schema.new(database:)
|
|
26
|
+
@filters = FilterCompiler.new(database:, schema: @schema)
|
|
27
|
+
@orders = OrderCompiler.new(schema: @schema)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load(node:)
|
|
31
|
+
root = NestingTree.new(schema: @schema).build(node:)
|
|
32
|
+
shape(node: root, rows: fetch(node: root))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def fetch(node:, filter: nil)
|
|
38
|
+
dataset = @filters.apply(dataset: @database[node.source.table].select(*select_columns(node:)), source: node.source)
|
|
39
|
+
dataset = dataset.where(filter) if filter
|
|
40
|
+
return per_parent(node:, dataset:) if filter && node.rows
|
|
41
|
+
|
|
42
|
+
@orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: [node.source.table]).all
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Keeps each parent's rows, e.g. each user's 3 most viewed posts, by numbering the rows within each parent.
|
|
46
|
+
def per_parent(node:, dataset:)
|
|
47
|
+
numbered = dataset.select_append(row_number(node:).as(ROW_NUMBER))
|
|
48
|
+
@database.from(numbered).where(ROW_NUMBER => row_numbers(rows: node.rows)).order(ROW_NUMBER)
|
|
49
|
+
.all.map { |row| row.except(ROW_NUMBER) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def row_number(node:)
|
|
53
|
+
table = node.source.table
|
|
54
|
+
order = @orders.order(keys: node.keys, tables: [table])
|
|
55
|
+
Sequel.function(:row_number).over(partition: Sequel[table][node.link.child_column], order: order.empty? ? nil : order)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Row numbers start at 1, and rows count from 0.
|
|
59
|
+
def row_numbers(rows:)
|
|
60
|
+
@orders.check_rows!(rows:)
|
|
61
|
+
first = rows.offset + 1
|
|
62
|
+
rows.limit ? first..(rows.offset + rows.limit) : (first..)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The columns asked for, plus the columns each link needs.
|
|
66
|
+
def select_columns(node:)
|
|
67
|
+
return [Sequel[node.source.table].*] if node.source.columns.empty?
|
|
68
|
+
|
|
69
|
+
link_columns = [node.link&.child_column, *node.children.map { |child| child.link.parent_column }].compact
|
|
70
|
+
(node.source.columns + link_columns).uniq
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Turns fetched rows into output hashes: the columns asked for, plus each child nested under its key.
|
|
74
|
+
def shape(node:, rows:)
|
|
75
|
+
nested = node.children.to_h { |child| [child, nested_rows(child:, rows:)] }
|
|
76
|
+
|
|
77
|
+
rows.map do |row|
|
|
78
|
+
output = node.source.columns.empty? ? row.dup : row.slice(*node.source.columns)
|
|
79
|
+
node.children.each { |child| output[child.key] = matches(child:, row:, nested: nested[child]) }
|
|
80
|
+
output
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def matches(child:, row:, nested:)
|
|
85
|
+
matches = nested.fetch(row[child.link.parent_column], [])
|
|
86
|
+
child.link.many ? matches : matches.first
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Fetches a child's rows for all parent rows in one query, grouped by the value that links them.
|
|
90
|
+
def nested_rows(child:, rows:)
|
|
91
|
+
values = rows.map { |row| row[child.link.parent_column] }.compact.uniq
|
|
92
|
+
return {} if values.empty?
|
|
93
|
+
|
|
94
|
+
child_rows = fetch(node: child, filter: { child.link.child_column => values })
|
|
95
|
+
child_rows.zip(shape(node: child, rows: child_rows))
|
|
96
|
+
.group_by { |row, _output| row[child.link.child_column] }
|
|
97
|
+
.transform_values { |pairs| pairs.map(&:last) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/nesting_tree.rb
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Builds the tree Nesting loads from an expression. Tables in brackets nest directly under their table, and the
|
|
5
|
+
# operands of a + chain nest by following foreign keys from the first operand.
|
|
6
|
+
class NestingTree
|
|
7
|
+
MULTIPLE_PATHS_MESSAGE = 'Foreign keys link %<tables>s by more than one path, so + has no single way to nest them. ' \
|
|
8
|
+
'Nest them in brackets instead, e.g. Users[:username, Posts[:title, Likes[]]]'
|
|
9
|
+
|
|
10
|
+
def initialize(schema:)
|
|
11
|
+
@schema = schema
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Builds the tree for an expression: a table with any tables nested in its brackets, or a + chain.
|
|
15
|
+
def build(node:)
|
|
16
|
+
raise JoinError, QueryCompiler::INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
|
|
17
|
+
return build(node: node.node).with(keys: node.keys, rows: node.rows) if node.is_a?(DataExpression::Ordered)
|
|
18
|
+
return bracketed(source: node) if node.is_a?(DataExpression::Source)
|
|
19
|
+
|
|
20
|
+
chain(operands: operands(node:).map { |operand| build(node: operand) })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the nested key and Link for a child table. On the "one" side a key matching a foreign key column picks it,
|
|
24
|
+
# so `author:` follows `author_id`. FilterCompiler links related tables the same way.
|
|
25
|
+
def link_to(parent:, child:, key:)
|
|
26
|
+
to_one = @schema.links(from: parent, to: child)
|
|
27
|
+
named = named_link(links: to_one, key:)
|
|
28
|
+
return one(parent:, child:, link: named, key:) if named
|
|
29
|
+
|
|
30
|
+
links = to_one + @schema.links(from: child, to: parent)
|
|
31
|
+
raise JoinError, "No foreign key links #{child} to #{parent}" if links.empty?
|
|
32
|
+
raise JoinError, many_keys_message(parent:, child:, to_one:) if links.length > 1
|
|
33
|
+
return one(parent:, child:, link: links.first, key:) if to_one.any?
|
|
34
|
+
|
|
35
|
+
many(parent:, child:, link: links.first, key:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# A + chain is left deep, and each operand is a table, a bracketed table or a parenthesised + chain.
|
|
41
|
+
def operands(node:)
|
|
42
|
+
return [node] unless node.is_a?(DataExpression::Join)
|
|
43
|
+
raise JoinError, QueryCompiler::NESTING_JOIN_MESSAGE unless node.type == :nest
|
|
44
|
+
|
|
45
|
+
operands(node: node.left) + [node.right]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Tables in brackets nest directly under the table whose brackets they're in.
|
|
49
|
+
def bracketed(source:)
|
|
50
|
+
children = source.nested.map do |nested|
|
|
51
|
+
child = build(node: nested.node)
|
|
52
|
+
key, link = link_to(parent: source.table, child: child.source.table, key: nested.key)
|
|
53
|
+
child.with(key:, link:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
with_children(node: Nesting::Node.new(source:, key: nil, link: nil, children: []), children:)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Links the operands into a tree from the first. Every operand must be linked, and only by one path.
|
|
60
|
+
def chain(operands:)
|
|
61
|
+
check_duplicates!(operands:)
|
|
62
|
+
|
|
63
|
+
edges = operands.combination(2).select { |operand, other| linked?(operand.source.table, other.source.table) }
|
|
64
|
+
visited = [operands.first]
|
|
65
|
+
root = attach(operand: operands.first, node: operands.first, edges:, visited:)
|
|
66
|
+
check_chain!(operands:, visited:, edges:)
|
|
67
|
+
|
|
68
|
+
root
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Nests each unvisited operand linked to `operand`. `node` is the operand with its key and link set.
|
|
72
|
+
def attach(operand:, node:, edges:, visited:)
|
|
73
|
+
neighbours = edges.filter_map { |pair| (pair - [operand]).first if pair.include?(operand) } - visited
|
|
74
|
+
visited.concat(neighbours)
|
|
75
|
+
|
|
76
|
+
children = neighbours.map do |child|
|
|
77
|
+
key, link = link_to(parent: node.source.table, child: child.source.table, key: nil)
|
|
78
|
+
attach(operand: child, node: child.with(key:, link:), edges:, visited:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
with_children(node:, children:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def with_children(node:, children:)
|
|
85
|
+
children = node.children + children
|
|
86
|
+
check_keys!(table: node.source.table, children:)
|
|
87
|
+
node.with(children:)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_duplicates!(operands:)
|
|
91
|
+
tables = operands.map { |operand| operand.source.table }
|
|
92
|
+
duplicate = tables.find { |table| tables.count(table) > 1 }
|
|
93
|
+
raise JoinError, "Can't join #{duplicate} to itself. Name each in brackets, e.g. author: Users[:username]" if duplicate
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_chain!(operands:, visited:, edges:)
|
|
97
|
+
tables = operands.map { |operand| operand.source.table }
|
|
98
|
+
unreached = (operands - visited).map { |operand| operand.source.table }
|
|
99
|
+
raise JoinError, "No foreign key links #{unreached.join(' and ')} to #{(tables - unreached).join(', ')}" if unreached.any?
|
|
100
|
+
raise JoinError, format(MULTIPLE_PATHS_MESSAGE, tables: tables.join(', ')) if edges.length > operands.length - 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_keys!(table:, children:)
|
|
104
|
+
keys = children.map(&:key)
|
|
105
|
+
duplicate = keys.find { |key| keys.count(key) > 1 }
|
|
106
|
+
raise JoinError, "Two tables nest in #{table} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def linked?(table, other)
|
|
110
|
+
@schema.links(from: table, to: other).any? || @schema.links(from: other, to: table).any?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def named_link(links:, key:)
|
|
114
|
+
named = links.select { |link| key && [key, :"#{key}_id"].include?(link[:columns].first) }
|
|
115
|
+
named.first if named.one?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `user_id` nests as `user:`, like the association name in most ORMs.
|
|
119
|
+
def one(parent:, child:, link:, key:)
|
|
120
|
+
column, foreign_key = sole_columns(parent:, child:, link:)
|
|
121
|
+
key ||= column.to_s.end_with?('_id') ? column.to_s.delete_suffix('_id').to_sym : child
|
|
122
|
+
[key, Nesting::Link.new(parent_column: column, child_column: foreign_key, many: false)]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def many(parent:, child:, link:, key:)
|
|
126
|
+
column, foreign_key = sole_columns(parent:, child:, link:)
|
|
127
|
+
[key || child, Nesting::Link.new(parent_column: foreign_key, child_column: column, many: true)]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sole_columns(parent:, child:, link:)
|
|
131
|
+
columns, keys = link.values_at(:columns, :keys)
|
|
132
|
+
raise JoinError, "Nesting doesn't support composite foreign keys between #{parent} and #{child}" if columns.length > 1
|
|
133
|
+
|
|
134
|
+
[columns.first, keys.first]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def many_keys_message(parent:, child:, to_one:)
|
|
138
|
+
message = "More than one foreign key links #{parent} and #{child}"
|
|
139
|
+
return message if to_one.empty?
|
|
140
|
+
|
|
141
|
+
names = to_one.map { |link| "#{link[:columns].first.to_s.delete_suffix('_id')}: #{LowData.constant_name(child)}[]" }
|
|
142
|
+
"#{message}. Name the one to follow, e.g. #{names.sort.join(' or ')}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Compiles sort keys into ORDER BY expressions and rows into LIMIT and OFFSET. Each column is qualified with the one
|
|
5
|
+
# table that has it, so `Users[:username] & Posts[:title] ^ Col[:views]` sorts by posts.views.
|
|
6
|
+
class OrderCompiler
|
|
7
|
+
UNPICKED_PAGE_MESSAGE = 'Pick a page after splitting rows into pages, e.g. (Posts[:title] / 20)[0]'
|
|
8
|
+
|
|
9
|
+
def initialize(schema:)
|
|
10
|
+
@schema = schema
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(dataset:, keys:, rows:, tables:)
|
|
14
|
+
dataset = dataset.order(*order(keys:, tables:)) if keys.any?
|
|
15
|
+
return dataset unless rows
|
|
16
|
+
|
|
17
|
+
check_rows!(rows:)
|
|
18
|
+
dataset.limit(rows.limit, rows.offset.zero? ? nil : rows.offset)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def order(keys:, tables:)
|
|
22
|
+
keys.map do |key|
|
|
23
|
+
column = Sequel[owner(column: key.column, tables:)][key.column]
|
|
24
|
+
key.descending ? column.desc : column
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_rows!(rows:)
|
|
29
|
+
raise ArgumentError, UNPICKED_PAGE_MESSAGE if rows.page_size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def owner(column:, tables:)
|
|
35
|
+
owners = tables.select { |table| @schema.columns(table:).include?(column) }
|
|
36
|
+
raise JoinError, "No table in #{tables.join(', ')} has a #{column} column to sort by" if owners.empty?
|
|
37
|
+
raise JoinError, "#{owners.join(' and ')} both have a #{column} column, so Col[:#{column}] is ambiguous" if owners.length > 1
|
|
38
|
+
|
|
39
|
+
owners.first
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Compiles a DataExpression join tree into a Sequel dataset, inferring each join's ON clause from foreign keys.
|
|
5
|
+
class QueryCompiler
|
|
6
|
+
INVERSION_JOIN_MESSAGE = "Table[] can't be joined with other expressions"
|
|
7
|
+
NESTING_JOIN_MESSAGE = 'Nested tables return nested data, so + and tables nested in brackets can\'t be combined with &, <, > or <=>'
|
|
8
|
+
ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users[] < Posts[]) ^ Col[:username]. Ruby binds ^ tighter ' \
|
|
9
|
+
'than <, > and <=>, and * and / tighter than every join, so add parentheses around the join'
|
|
10
|
+
|
|
11
|
+
def initialize(database:)
|
|
12
|
+
raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
|
|
13
|
+
|
|
14
|
+
@database = database
|
|
15
|
+
@schema = Schema.new(database:)
|
|
16
|
+
@filters = FilterCompiler.new(database:, schema: @schema)
|
|
17
|
+
@orders = OrderCompiler.new(schema: @schema)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compile(node:)
|
|
21
|
+
return compile_ordered(node:) if node.is_a?(DataExpression::Ordered)
|
|
22
|
+
|
|
23
|
+
compile_unordered(node:, sources: [])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Sort keys and rows apply to the whole query, so each sort column can belong to any joined table.
|
|
29
|
+
def compile_ordered(node:)
|
|
30
|
+
sources = []
|
|
31
|
+
dataset = compile_unordered(node: node.node, sources:)
|
|
32
|
+
@orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: sources.map(&:table))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def compile_unordered(node:, sources:)
|
|
36
|
+
return compile_inversion(node:, sources:) if node.is_a?(DataExpression::Inversion)
|
|
37
|
+
|
|
38
|
+
dataset = filter(dataset: build(node:, sources:), sources:)
|
|
39
|
+
dataset.select(*sources.flat_map { |source| selections(source:) })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def compile_inversion(node:, sources:)
|
|
43
|
+
tree, selections = Inversion.new(schema: @schema).resolve(node:)
|
|
44
|
+
filter(dataset: build(node: tree, sources:), sources:).select(*selections)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build(node:, sources:)
|
|
48
|
+
raise JoinError, INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
|
|
49
|
+
raise JoinError, ORDERED_JOIN_MESSAGE if node.is_a?(DataExpression::Ordered)
|
|
50
|
+
return source_dataset(source: node, sources:) if node.is_a?(DataExpression::Source)
|
|
51
|
+
raise JoinError, NESTING_JOIN_MESSAGE if nesting?(node:) || nesting?(node: node.right)
|
|
52
|
+
|
|
53
|
+
raise JoinError, nested_join_message if node.right.is_a?(DataExpression::Join)
|
|
54
|
+
|
|
55
|
+
join_dataset(dataset: build(node: node.left, sources:), type: node.type, right: node.right, sources:)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def join_dataset(dataset:, type:, right:, sources:)
|
|
59
|
+
raise JoinError, INVERSION_JOIN_MESSAGE if right.is_a?(DataExpression::Inversion)
|
|
60
|
+
raise JoinError, ORDERED_JOIN_MESSAGE if right.is_a?(DataExpression::Ordered)
|
|
61
|
+
raise JoinError, NESTING_JOIN_MESSAGE if right.nested.any?
|
|
62
|
+
raise JoinError, "Can't join #{right.table} to itself" if sources.any? { |source| source.table == right.table }
|
|
63
|
+
|
|
64
|
+
conditions = join_conditions(left_tables: sources.map(&:table), right_table: right.table)
|
|
65
|
+
sources << right
|
|
66
|
+
dataset.join_table(type, right.table, conditions)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def nesting?(node:)
|
|
70
|
+
node.is_a?(DataExpression::Join) && node.type == :nest
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def source_dataset(source:, sources:)
|
|
74
|
+
raise JoinError, NESTING_JOIN_MESSAGE if source.nested.any?
|
|
75
|
+
|
|
76
|
+
sources << source
|
|
77
|
+
@database[source.table]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every table's filters go in the WHERE clause, so they apply to the joined rows.
|
|
81
|
+
def filter(dataset:, sources:)
|
|
82
|
+
sources.reduce(dataset) { |filtered, source| @filters.apply(dataset: filtered, source:) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def selections(source:)
|
|
86
|
+
return [Sequel[source.table].*] if source.columns.empty?
|
|
87
|
+
|
|
88
|
+
source.columns.map { |column| Sequel[source.table][column] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def join_conditions(left_tables:, right_table:)
|
|
92
|
+
candidates = left_tables.flat_map do |left_table|
|
|
93
|
+
@schema.references(from: right_table, to: left_table) + @schema.references(from: left_table, to: right_table)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
raise JoinError, "No foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.empty?
|
|
97
|
+
raise JoinError, "More than one foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.length > 1
|
|
98
|
+
|
|
99
|
+
candidates.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def nested_join_message
|
|
103
|
+
'Ruby evaluated a join on the right first, e.g. `Users[] < Posts[] & Comments[]` runs `Posts[] & Comments[]` first ' \
|
|
104
|
+
'because & binds tighter than <. Add parentheses to join from the left: `(Users[] < Posts[]) & Comments[]`'
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/rows.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Which sorted rows to keep. Rows count from 0, as in Ruby:
|
|
5
|
+
# Posts[] * 10 # LIMIT 10
|
|
6
|
+
# (Posts[] / 20)[2] # LIMIT 20 OFFSET 40, the third page of 20
|
|
7
|
+
# Posts[] ^ Col[:id][20...40] # LIMIT 20 OFFSET 20
|
|
8
|
+
# Pages have a `page_size` until a page is picked, and a `limit` and `offset` after.
|
|
9
|
+
Rows = ::Data.define(:offset, :limit, :page_size) do
|
|
10
|
+
def self.limit(count)
|
|
11
|
+
new(offset: 0, limit: positive!(count:, operator: '*'), page_size: nil)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.pages(size)
|
|
15
|
+
new(offset: 0, limit: nil, page_size: positive!(count: size, operator: '/'))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.range(range)
|
|
19
|
+
check_range!(range:)
|
|
20
|
+
first = range.begin || 0
|
|
21
|
+
return new(offset: first, limit: nil, page_size: nil) if range.end.nil?
|
|
22
|
+
|
|
23
|
+
new(offset: first, limit: [range.end - first + (range.exclude_end? ? 0 : 1), 0].max, page_size: nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# One limit, range or page size per expression.
|
|
27
|
+
def self.merge(rows, other)
|
|
28
|
+
raise ArgumentError, 'An expression takes one limit, range or page size, not two' if rows && other
|
|
29
|
+
|
|
30
|
+
rows || other
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.check_range!(range:)
|
|
34
|
+
unless range.is_a?(Range) && [range.begin, range.end].all? { |bound| bound.nil? || bound.is_a?(Integer) }
|
|
35
|
+
raise ArgumentError, "Pick rows with a range of integers, e.g. Col[:views][20...40], not #{range.inspect}"
|
|
36
|
+
end
|
|
37
|
+
raise ArgumentError, "Rows count from 0, so #{range.inspect} can't start below 0" if range.begin&.negative?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.positive!(count:, operator:)
|
|
41
|
+
return count if count.is_a?(Integer) && count.positive?
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "#{operator} takes a positive integer, e.g. Posts[:title] #{operator} 10, not #{count.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def page(number)
|
|
47
|
+
raise ArgumentError, 'Split rows into pages before picking one, e.g. (Posts[:title] / 20)[0]' unless page_size
|
|
48
|
+
unless number.is_a?(Integer) && !number.negative?
|
|
49
|
+
raise ArgumentError, "Pages count from 0, so pick one with an integer from 0, not #{number.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
with(offset: number * page_size, limit: page_size, page_size: nil)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/schema.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Reads tables, columns and foreign keys from the database, caching each lookup.
|
|
5
|
+
class Schema
|
|
6
|
+
def initialize(database:)
|
|
7
|
+
@database = database
|
|
8
|
+
@schemas = {}
|
|
9
|
+
@foreign_keys = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def columns(table:)
|
|
13
|
+
schema(table:).map(&:first)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def primary_key(table:)
|
|
17
|
+
schema(table:).select { |_column, info| info[:primary_key] }.map(&:first)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def foreign_keys(table:)
|
|
21
|
+
@foreign_keys[table] ||= @database.foreign_key_list(table)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Tables with a foreign key to or from the table.
|
|
25
|
+
def related_tables(table:)
|
|
26
|
+
outgoing = foreign_keys(table:).map { |foreign_key| foreign_key[:table] }
|
|
27
|
+
incoming = @database.tables.select do |other|
|
|
28
|
+
foreign_keys(table: other).any? { |foreign_key| foreign_key[:table] == table }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
(outgoing + incoming).uniq - [table]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Foreign keys on the `from` table that reference the `to` table, as join conditions.
|
|
35
|
+
def references(from:, to:)
|
|
36
|
+
links(from:, to:).map do |link|
|
|
37
|
+
link[:columns].zip(link[:keys]).to_h { |column, key| [Sequel[from][column], Sequel[to][key]] }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Foreign keys on the `from` table that reference the `to` table, as their columns and the keys they reference.
|
|
42
|
+
def links(from:, to:)
|
|
43
|
+
foreign_keys(table: from).select { |foreign_key| foreign_key[:table] == to }.map do |foreign_key|
|
|
44
|
+
{ columns: foreign_key[:columns], keys: foreign_key[:key] || primary_key(table: to) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def schema(table:)
|
|
51
|
+
@schemas[table] ||= @database.schema(table)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# A table named by a constant, such as `Users`, waiting for its columns: `Users[:username]`.
|
|
5
|
+
# An inverted reference is `Table`, standing in for the repository's table and any table related to it.
|
|
6
|
+
# A reference can carry filters from round brackets, `Posts(published: true)`, and be negated with `~Posts`.
|
|
7
|
+
class TableReference
|
|
8
|
+
FILTER_KEYWORD_MESSAGE = 'Filters go in round brackets, e.g. Posts(published: true)[:title], not %<keywords>s'
|
|
9
|
+
|
|
10
|
+
attr_reader :table, :filter, :negated
|
|
11
|
+
|
|
12
|
+
def initialize(table:, inverted: false, filter: DataExpression::Filter.new, negated: false)
|
|
13
|
+
@table = table
|
|
14
|
+
@inverted = inverted
|
|
15
|
+
@filter = filter
|
|
16
|
+
@negated = negated
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Each argument's type decides its meaning:
|
|
20
|
+
# Posts[:title] # A symbol is a column. No columns selects every column.
|
|
21
|
+
# Users[:username, Posts[:title]] # A data expression is a nested table.
|
|
22
|
+
# Reviews[:rating, author: Users[:username]] # A keyword with a data expression names a nested table.
|
|
23
|
+
def [](*arguments, **keywords)
|
|
24
|
+
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
|
|
25
|
+
return inverted(arguments:, keywords:) if @inverted
|
|
26
|
+
|
|
27
|
+
expressions, columns = arguments.partition { |argument| argument.is_a?(DataExpression) }
|
|
28
|
+
check_columns!(columns:)
|
|
29
|
+
nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named(keywords:))
|
|
30
|
+
|
|
31
|
+
DataExpression.new(node: DataExpression::Source.new(table:, columns:, nested:, filter:))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Each argument's type decides its meaning:
|
|
35
|
+
# Posts(published: true) # A keyword with a value filters by that value.
|
|
36
|
+
# Posts { views > 100 } # A block is a Sequel virtual row condition.
|
|
37
|
+
# Users(Posts), Users(~Posts) # A table keeps rows with, or without, related rows.
|
|
38
|
+
# Users(Posts => 3..) # A table as a key keeps rows with that many related rows.
|
|
39
|
+
# Reviews(author: Users(username: 'alice')) # A keyword with a table names the foreign key to follow.
|
|
40
|
+
def where(*arguments, **keywords, &block)
|
|
41
|
+
related = arguments.map { |argument| related(key: nil, reference: argument) }
|
|
42
|
+
values = {}
|
|
43
|
+
|
|
44
|
+
keywords.each do |key, value|
|
|
45
|
+
if key.is_a?(TableReference) then related << related(key: nil, reference: key, count: value)
|
|
46
|
+
elsif value.is_a?(TableReference) then related << related(key:, reference: value)
|
|
47
|
+
else values[key] = value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
conditions = block ? [Sequel.virtual_row(&block)] : []
|
|
52
|
+
with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `~Posts` keeps rows without related posts.
|
|
56
|
+
def ~
|
|
57
|
+
with(negated: !negated)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source
|
|
61
|
+
DataExpression::Source.new(table:, columns: [], filter:)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def with(filter: @filter, negated: @negated)
|
|
67
|
+
self.class.new(table:, inverted: @inverted, filter:, negated:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def related(key:, reference:, count: nil)
|
|
71
|
+
unless reference.is_a?(TableReference)
|
|
72
|
+
raise ArgumentError, "Round brackets take filters and tables, e.g. Posts(published: true) or Users(Posts), not #{reference.inspect}"
|
|
73
|
+
end
|
|
74
|
+
raise ArgumentError, "Write ~#{LowData.constant_name(reference.table)} or a count, not both" if reference.negated && count
|
|
75
|
+
|
|
76
|
+
DataExpression::Related.new(key:, source: reference.source, count: count || (reference.negated ? 0 : 1..))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def named(keywords:)
|
|
80
|
+
named, filters = keywords.partition { |_key, value| value.is_a?(DataExpression) }
|
|
81
|
+
raise ArgumentError, format(FILTER_KEYWORD_MESSAGE, keywords: filters.to_h) if filters.any?
|
|
82
|
+
|
|
83
|
+
named
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def nested(pairs:)
|
|
87
|
+
pairs.map { |key, expression| DataExpression::Nested.new(key:, node: expression.node) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inverted(arguments:, keywords:)
|
|
91
|
+
check_columns!(columns: arguments)
|
|
92
|
+
raise ArgumentError, 'Table[] takes column names only' if keywords.any?
|
|
93
|
+
|
|
94
|
+
DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, filter:))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check_columns!(columns:)
|
|
98
|
+
invalid = columns.reject { |column| column.is_a?(Symbol) }
|
|
99
|
+
raise ArgumentError, "Columns are symbols, e.g. Posts[:title], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/version.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lowdata
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- maedi
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: expressions
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: lowtype
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.6'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.6'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sequel
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
description: The repository pattern with data expressions + the usual query building
|
|
55
|
+
email:
|
|
56
|
+
- maediprichard@gmail.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- lib/col.rb
|
|
62
|
+
- lib/data_expression.rb
|
|
63
|
+
- lib/errors.rb
|
|
64
|
+
- lib/filter_compiler.rb
|
|
65
|
+
- lib/inversion.rb
|
|
66
|
+
- lib/lowdata.rb
|
|
67
|
+
- lib/nesting.rb
|
|
68
|
+
- lib/nesting_tree.rb
|
|
69
|
+
- lib/order_compiler.rb
|
|
70
|
+
- lib/query_compiler.rb
|
|
71
|
+
- lib/rows.rb
|
|
72
|
+
- lib/schema.rb
|
|
73
|
+
- lib/table_reference.rb
|
|
74
|
+
- lib/version.rb
|
|
75
|
+
homepage: https://github.com/low-rb/lowdata
|
|
76
|
+
licenses: []
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/low-rb/lowdata
|
|
79
|
+
source_code_uri: https://github.com/low-rb/lowdata/src/branch/main
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.3.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 4.0.6
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: The repository pattern with a twist; Data Expressions
|
|
97
|
+
test_files: []
|