lowdata 0.2.0 → 0.3.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 +4 -4
- data/lib/data_expression/source.rb +27 -0
- data/lib/data_expression.rb +6 -20
- data/lib/lowdata.rb +9 -3
- data/lib/nesting.rb +3 -3
- data/lib/nesting_tree.rb +8 -0
- data/lib/order_compiler.rb +3 -1
- data/lib/query_compiler.rb +31 -14
- data/lib/rows.rb +3 -3
- data/lib/schema.rb +2 -1
- data/lib/table_reference/writes.rb +70 -0
- data/lib/table_reference.rb +33 -49
- data/lib/version.rb +1 -1
- data/lib/write_checks.rb +13 -0
- data/lib/writer.rb +21 -9
- metadata +4 -2
- /data/lib/{expression_writes.rb → data_expression/writes.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00edcbe16f363ff5571130711b163c2b7a30f269b063237d9513f0d0d65b1ac9
|
|
4
|
+
data.tar.gz: b76da81116417a88cb6d440acfcd676aee2f9f53726c8b079551e4ab6df5279f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76724feffd8a520b11e60bd387ebf1a3aa7543fc7bb962af8b4b5db4411f9099fa42cef7207c98592cf6687a393076b51f3ffad45b198e32b885f8636accac93
|
|
7
|
+
data.tar.gz: f555c5e21c68df568a8e02e511f9fd6ee8ced5ca9a97edc799eb95415e96fb56c38b2711eb161546fe7c00b775905c1418127b146caddb474a96c9ede039062b
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
class DataExpression < ::Expressions::Expression
|
|
5
|
+
# A table with the columns to select, the columns renamed (`headline: :title`), the tables nested in its brackets and
|
|
6
|
+
# the filter on its rows. `bare` is a table without brackets, `Posts`, which selects every column. See ADR 0006.
|
|
7
|
+
Source = ::Data.define(:table, :columns, :renames, :nested, :filter, :bare) do
|
|
8
|
+
def initialize(table:, columns:, renames: {}, nested: [], filter: Filter.new, bare: false)
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Source
|
|
14
|
+
# A bare table, `Posts`, selects every column.
|
|
15
|
+
def every_column? = bare
|
|
16
|
+
|
|
17
|
+
# Empty brackets, `Posts[]`, select no columns.
|
|
18
|
+
def no_columns? = !bare && columns.empty? && renames.empty?
|
|
19
|
+
|
|
20
|
+
# The keys of each row, as asked for.
|
|
21
|
+
def names = columns + renames.keys
|
|
22
|
+
|
|
23
|
+
# The renamed columns, as `posts.title AS headline`.
|
|
24
|
+
def aliases = renames.map { |name, column| Sequel[table][column].as(name) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/data_expression.rb
CHANGED
|
@@ -27,30 +27,12 @@ class LowData
|
|
|
27
27
|
end
|
|
28
28
|
# A table in another table's round brackets, keeping rows with `count` related rows: `Users(Posts => 3..)`.
|
|
29
29
|
Related = ::Data.define(:key, :source, :count)
|
|
30
|
-
# A table with the columns to select, the columns renamed (`headline: :title`), the tables nested in its brackets and
|
|
31
|
-
# the filter on its rows.
|
|
32
|
-
Source = ::Data.define(:table, :columns, :renames, :nested, :filter) do
|
|
33
|
-
def initialize(table:, columns:, renames: {}, nested: [], filter: Filter.new)
|
|
34
|
-
super
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
class Source
|
|
39
|
-
# No columns or renames selects every column.
|
|
40
|
-
def every_column? = columns.empty? && renames.empty?
|
|
41
|
-
|
|
42
|
-
# The keys of each row, as asked for.
|
|
43
|
-
def names = columns + renames.keys
|
|
44
|
-
|
|
45
|
-
# The renamed columns, as `posts.title AS headline`.
|
|
46
|
-
def aliases = renames.map { |name, column| Sequel[table][column].as(name) }
|
|
47
|
-
end
|
|
48
30
|
# A table nested in another table's brackets, under an optional key: `Reviews[:rating, author: Users[:username]]`.
|
|
49
31
|
Nested = ::Data.define(:key, :node)
|
|
50
32
|
Join = ::Data.define(:type, :left, :right) do
|
|
51
33
|
def nest? = type == :nest
|
|
52
34
|
end
|
|
53
|
-
# Tables nested side by side in the table before them: `Posts[:title] + [Comments[:body], Likes
|
|
35
|
+
# Tables nested side by side in the table before them: `Posts[:title] + [Comments[:body], Likes]`.
|
|
54
36
|
Siblings = ::Data.define(:nodes)
|
|
55
37
|
Inversion = ::Data.define(:table, :columns, :renames, :filter)
|
|
56
38
|
# A node sorted by `keys` (Col::Key) with `rows` (Rows) kept: `Posts[:title] ^ -Col[:views] * 10`.
|
|
@@ -67,6 +49,7 @@ class LowData
|
|
|
67
49
|
SORT_MESSAGE = 'Sort by columns with Col[], e.g. Posts[:title] ^ Col[:views], not %<value>s'
|
|
68
50
|
NESTED_DATASET_MESSAGE = 'Nested tables load with one query per table, so they have no single dataset. Call sql to see ' \
|
|
69
51
|
'each query, or to_a to load them'
|
|
52
|
+
|
|
70
53
|
def dataset(database: LowData.database)
|
|
71
54
|
raise Error, NESTED_DATASET_MESSAGE if nested?
|
|
72
55
|
|
|
@@ -109,9 +92,10 @@ class LowData
|
|
|
109
92
|
end
|
|
110
93
|
|
|
111
94
|
def add_siblings(expressions)
|
|
95
|
+
expressions = expressions.map { |expression| TableReference.expression(expression) }
|
|
112
96
|
invalid = expressions.reject { |expression| expression.is_a?(DataExpression) }
|
|
113
97
|
raise ArgumentError, "Nest data expressions such as Comments[:body], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
|
|
114
|
-
raise ArgumentError, 'Nest at least one table, e.g. Posts[:title] + [Comments[:body], Likes
|
|
98
|
+
raise ArgumentError, 'Nest at least one table, e.g. Posts[:title] + [Comments[:body], Likes]' if expressions.empty?
|
|
115
99
|
|
|
116
100
|
@node = Join.new(type: :nest, left: @node, right: Siblings.new(nodes: expressions.map(&:node)))
|
|
117
101
|
end
|
|
@@ -153,7 +137,9 @@ class LowData
|
|
|
153
137
|
@node = Join.new(type:, left: @node, right: expression.node)
|
|
154
138
|
end
|
|
155
139
|
|
|
140
|
+
# A bare table on the right, `Users[:username] + Posts`, joins as every column of the table.
|
|
156
141
|
define_method(:"#{prefix}_value") do |value|
|
|
142
|
+
return send(:"#{prefix}_expression", value.to_expression) if value.is_a?(TableReference)
|
|
157
143
|
return add_siblings(value) if prefix == :add && value.is_a?(Array)
|
|
158
144
|
|
|
159
145
|
raise ArgumentError, "Join a data expression such as Posts[:title], not the value #{value.inspect}"
|
data/lib/lowdata.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'sequel'
|
|
|
7
7
|
require_relative 'version'
|
|
8
8
|
require_relative 'errors'
|
|
9
9
|
require_relative 'data_expression'
|
|
10
|
+
require_relative 'data_expression/source'
|
|
10
11
|
require_relative 'rows'
|
|
11
12
|
require_relative 'col'
|
|
12
13
|
require_relative 'order_compiler'
|
|
@@ -19,8 +20,9 @@ require_relative 'nesting_tree'
|
|
|
19
20
|
require_relative 'nesting_path'
|
|
20
21
|
require_relative 'query_compiler'
|
|
21
22
|
require_relative 'writer'
|
|
22
|
-
require_relative '
|
|
23
|
+
require_relative 'data_expression/writes'
|
|
23
24
|
require_relative 'table_reference'
|
|
25
|
+
require_relative 'table_reference/writes'
|
|
24
26
|
|
|
25
27
|
class LowData
|
|
26
28
|
class << self
|
|
@@ -72,8 +74,12 @@ class LowData
|
|
|
72
74
|
# Round brackets after a table filter it: `Posts(published: true) { views > 100 }[:title]`. A capitalised name is a
|
|
73
75
|
# table only when the database has that table, so typos still raise NoMethodError. `Table(...)` is always a table.
|
|
74
76
|
def method_missing(name, *arguments, **keywords, &block)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
return super unless name.match?(/\A[A-Z]/)
|
|
78
|
+
raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil?
|
|
79
|
+
|
|
80
|
+
# Raised rather than left to super, which can report a NameError instead when looking up the tables runs a
|
|
81
|
+
# method_missing of its own, as Postgres' adapter does.
|
|
82
|
+
raise NoMethodError.new("undefined method '#{name}' for an instance of #{self.class}", name) unless table_method?(name:)
|
|
77
83
|
|
|
78
84
|
self.class.const_missing(name).where(*arguments, **keywords, &block)
|
|
79
85
|
end
|
data/lib/nesting.rb
CHANGED
|
@@ -58,14 +58,14 @@ class LowData
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def load(node:)
|
|
61
|
-
root = NestingTree.new(schema: @schema).
|
|
61
|
+
root = NestingTree.new(schema: @schema).root(node:)
|
|
62
62
|
shape(node: root, rows: dataset(node: root).all)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# The SQL of each query `load` runs, parents first, without running them. A nested table's query shows the parent
|
|
66
66
|
# values it's filtered by as `IN (...)`.
|
|
67
67
|
def sql(node:)
|
|
68
|
-
queries(node: NestingTree.new(schema: @schema).
|
|
68
|
+
queries(node: NestingTree.new(schema: @schema).root(node:)).map(&:sql)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
private
|
|
@@ -92,7 +92,7 @@ class LowData
|
|
|
92
92
|
def join_dataset(node:)
|
|
93
93
|
parents = node.children.map { |child| Sequel[child.link.parent_table][child.link.parent_column].as(child.link.parent_key) }
|
|
94
94
|
links = node.link ? [link_column(node:).as(LINK)] : []
|
|
95
|
-
@queries.compile(node: node.join.node
|
|
95
|
+
@queries.compile(node: node.join.node, links: [*parents.uniq, *links])
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def link_column(node:)
|
data/lib/nesting_tree.rb
CHANGED
|
@@ -13,6 +13,14 @@ class LowData
|
|
|
13
13
|
assemble(entry: entry(node:))
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The tree whose first table is returned, so empty brackets, which select none of its columns, raise.
|
|
17
|
+
def root(node:)
|
|
18
|
+
root = build(node:)
|
|
19
|
+
raise ArgumentError, QueryCompiler.no_columns_message(table: root.source.table) if !root.join && root.source.no_columns?
|
|
20
|
+
|
|
21
|
+
root
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
# An expression's node, waiting for the children NestingPath nests in it.
|
|
17
25
|
def entry(node:)
|
|
18
26
|
raise JoinError, QueryCompiler::INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
|
data/lib/order_compiler.rb
CHANGED
|
@@ -18,10 +18,12 @@ class LowData
|
|
|
18
18
|
dataset.limit(rows.limit, rows.offset.zero? ? nil : rows.offset)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Rows without a value sort last in both directions, so `-Col[:views] * 10` is the 10 most viewed on every database.
|
|
22
|
+
# Postgres puts NULLs last ascending and first descending, and SQLite and MySQL the reverse.
|
|
21
23
|
def order(keys:, tables:)
|
|
22
24
|
keys.map do |key|
|
|
23
25
|
column = Sequel[owner(column: key.column, tables:)][key.column]
|
|
24
|
-
key.descending ? column.desc : column
|
|
26
|
+
key.descending ? column.desc(nulls: :last) : column.asc(nulls: :last)
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
|
data/lib/query_compiler.rb
CHANGED
|
@@ -6,7 +6,8 @@ class LowData
|
|
|
6
6
|
INVERSION_JOIN_MESSAGE = "Table[] can't be joined with other expressions"
|
|
7
7
|
NESTING_JOIN_MESSAGE = 'Nested tables return nested data, so + and tables nested in brackets can\'t be combined with &, <, > ' \
|
|
8
8
|
'or <=>. Nest the join instead, e.g. Users[:username] + (Posts[:title] & Comments[:body])'
|
|
9
|
-
|
|
9
|
+
FULL_JOIN_MESSAGE = 'MySQL has no FULL JOIN, so <=> only runs on other databases. Use < or > for a left or right join'
|
|
10
|
+
ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users < Posts) ^ Col[:username]. Ruby binds ^ tighter ' \
|
|
10
11
|
'than <, > and <=>, and * and / tighter than every join, so add parentheses around the join'
|
|
11
12
|
|
|
12
13
|
def initialize(database:)
|
|
@@ -18,26 +19,37 @@ class LowData
|
|
|
18
19
|
@orders = OrderCompiler.new(schema: @schema)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# `links` are columns a nested join selects to link its rows, as well as the columns asked for. See Nesting.
|
|
23
|
+
def compile(node:, links: [])
|
|
24
|
+
return compile_ordered(node:, links:) if node.is_a?(DataExpression::Ordered)
|
|
23
25
|
|
|
24
|
-
compile_unordered(node:, sources: [])
|
|
26
|
+
compile_unordered(node:, sources: [], links:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Empty brackets select no columns, so a query of only those has nothing to return.
|
|
30
|
+
def self.no_columns_message(table:)
|
|
31
|
+
name = LowData.constant_name(table)
|
|
32
|
+
"Empty brackets select no columns, so #{name}[] returns nothing. Write #{name} for every column, or name them, " \
|
|
33
|
+
"e.g. #{name}[:id]"
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
private
|
|
28
37
|
|
|
29
38
|
# Sort keys and rows apply to the whole query, so each sort column can belong to any joined table.
|
|
30
|
-
def compile_ordered(node:)
|
|
39
|
+
def compile_ordered(node:, links:)
|
|
31
40
|
sources = []
|
|
32
|
-
dataset = compile_unordered(node: node.node, sources:)
|
|
41
|
+
dataset = compile_unordered(node: node.node, sources:, links:)
|
|
33
42
|
@orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: sources.map(&:table))
|
|
34
43
|
end
|
|
35
44
|
|
|
36
|
-
def compile_unordered(node:, sources:)
|
|
45
|
+
def compile_unordered(node:, sources:, links:)
|
|
37
46
|
return compile_inversion(node:, sources:) if node.is_a?(DataExpression::Inversion)
|
|
38
47
|
|
|
39
48
|
dataset = filter(dataset: build(node:, sources:), sources:)
|
|
40
|
-
|
|
49
|
+
selections = sources.flat_map { |source| selections(source:) } + links
|
|
50
|
+
raise ArgumentError, QueryCompiler.no_columns_message(table: sources.first.table) if selections.empty?
|
|
51
|
+
|
|
52
|
+
dataset.select(*selections)
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def compile_inversion(node:, sources:)
|
|
@@ -57,14 +69,18 @@ class LowData
|
|
|
57
69
|
end
|
|
58
70
|
|
|
59
71
|
def join_dataset(dataset:, type:, right:, sources:)
|
|
72
|
+
check_join!(type:, right:, sources:)
|
|
73
|
+
conditions = join_conditions(left: sources, right:)
|
|
74
|
+
sources << right
|
|
75
|
+
dataset.join_table(type, right.table, conditions)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_join!(type:, right:, sources:)
|
|
60
79
|
raise JoinError, INVERSION_JOIN_MESSAGE if right.is_a?(DataExpression::Inversion)
|
|
61
80
|
raise JoinError, ORDERED_JOIN_MESSAGE if right.is_a?(DataExpression::Ordered)
|
|
62
81
|
raise JoinError, NESTING_JOIN_MESSAGE if right.nested.any?
|
|
63
82
|
raise JoinError, "Can't join #{right.table} to itself" if sources.any? { |source| source.table == right.table }
|
|
64
|
-
|
|
65
|
-
conditions = join_conditions(left: sources, right:)
|
|
66
|
-
sources << right
|
|
67
|
-
dataset.join_table(type, right.table, conditions)
|
|
83
|
+
raise JoinError, FULL_JOIN_MESSAGE if type == :full && @database.database_type == :mysql
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
def nesting?(node:)
|
|
@@ -83,6 +99,7 @@ class LowData
|
|
|
83
99
|
sources.reduce(dataset) { |filtered, source| @filters.apply(dataset: filtered, source:) }
|
|
84
100
|
end
|
|
85
101
|
|
|
102
|
+
# A bare table selects every column, and empty brackets select none.
|
|
86
103
|
def selections(source:)
|
|
87
104
|
return [Sequel[source.table].*] if source.every_column?
|
|
88
105
|
|
|
@@ -116,8 +133,8 @@ class LowData
|
|
|
116
133
|
end
|
|
117
134
|
|
|
118
135
|
def nested_join_message
|
|
119
|
-
'Ruby evaluated a join on the right first, e.g. `Users
|
|
120
|
-
'because & binds tighter than <. Add parentheses to join from the left: `(Users
|
|
136
|
+
'Ruby evaluated a join on the right first, e.g. `Users < Posts & Comments` runs `Posts & Comments` first ' \
|
|
137
|
+
'because & binds tighter than <. Add parentheses to join from the left: `(Users < Posts) & Comments`'
|
|
121
138
|
end
|
|
122
139
|
end
|
|
123
140
|
end
|
data/lib/rows.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class LowData
|
|
4
4
|
# Which sorted rows to keep. Rows count from 0, as in Ruby:
|
|
5
|
-
# Posts
|
|
6
|
-
# (Posts
|
|
7
|
-
# Posts
|
|
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
8
|
# Pages have a `page_size` until a page is picked, and a `limit` and `offset` after.
|
|
9
9
|
Rows = ::Data.define(:offset, :limit, :page_size) do
|
|
10
10
|
def self.limit(count)
|
data/lib/schema.rb
CHANGED
|
@@ -28,7 +28,8 @@ class LowData
|
|
|
28
28
|
foreign_keys(table: other).any? { |foreign_key| foreign_key[:table] == table }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Sorted, because databases list their tables in different orders, and error messages name the first.
|
|
32
|
+
((outgoing + incoming).uniq - [table]).sort
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Foreign keys on the `from` table that reference the `to` table, as join conditions. `named` keeps only the foreign
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
class TableReference
|
|
5
|
+
# Writes run straight away. See Writer:
|
|
6
|
+
# Posts << { title: 'Hello' } # Inserts, returning the new row's primary key.
|
|
7
|
+
# Posts(slug: 'hello') <= { title: 'Hello' } # Upserts, matching on the round brackets.
|
|
8
|
+
# Posts(id: 1)[:title] = 'New' # Updates the rows chosen by the round brackets.
|
|
9
|
+
# Posts(id: 1).delete # Deletes them, returning how many.
|
|
10
|
+
module Writes
|
|
11
|
+
def <<(rows)
|
|
12
|
+
writer.insert(source: write_source, rows:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def <=(rows)
|
|
16
|
+
writer.upsert(source: write_source, rows:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Ruby makes an assignment return its right-hand side, so `Posts(id: 1)[:title] = 'New'` returns 'New'.
|
|
20
|
+
def []=(*columns, value)
|
|
21
|
+
writer.update(source: write_source, values: assignments(columns:, value:))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delete
|
|
25
|
+
writer.delete(source: write_source)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def writer
|
|
31
|
+
Writer.new(database: LowData.database)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write_source
|
|
35
|
+
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't be written to" if negated
|
|
36
|
+
|
|
37
|
+
source
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The columns in square brackets and the value assigned to them:
|
|
41
|
+
# Posts(id: 1)[:title] = 'New' # One column takes a value.
|
|
42
|
+
# Posts(id: 1)[:title, :body] = ['New', '...'] # Several columns take an array of as many values.
|
|
43
|
+
# Posts(id: 1)[] = { title: 'New' } # Empty brackets take a hash of columns and values.
|
|
44
|
+
def assignments(columns:, value:)
|
|
45
|
+
check_columns!(columns:)
|
|
46
|
+
return assigned_hash(value:) if columns.empty?
|
|
47
|
+
return { columns.first => assigned_value(value:) } if columns.one?
|
|
48
|
+
return columns.zip(value).to_h if value.is_a?(Array) && value.length == columns.length
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "#{columns.length} columns take #{columns.length} values, e.g. Posts(id: 1)[:title, :body] = ['New', '...']"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sequel would write a hash into one column as a condition, storing 0 or 1, so a hash for one column raises.
|
|
54
|
+
def assigned_value(value:)
|
|
55
|
+
return value unless value.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, 'One column takes one value, not a hash. To set the columns in a hash, use empty brackets, e.g. ' \
|
|
58
|
+
"Posts(id: 1)[] = { title: 'New' }"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assigned_hash(value:)
|
|
62
|
+
return value if value.is_a?(Hash) && value.any?
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "Empty brackets take a hash of columns and values, e.g. Posts(id: 1)[] = { title: 'New' }"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
include Writes
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/table_reference.rb
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class LowData
|
|
4
|
-
# A table named by a constant, such as `Users
|
|
4
|
+
# A table named by a constant, such as `Users`. Square brackets choose its columns, `Users[:username]`, and on its own
|
|
5
|
+
# it's every column. See ADR 0006.
|
|
5
6
|
# An inverted reference is `Table`, standing in for the repository's table and any table related to it.
|
|
6
7
|
# A reference can carry filters from round brackets, `Posts(published: true)`, and be negated with `~Posts`.
|
|
7
8
|
class TableReference
|
|
8
9
|
FILTER_KEYWORD_MESSAGE = 'Filters go in round brackets, e.g. Posts(published: true)[:title], not %<keywords>s'
|
|
10
|
+
BARE_TABLE_MESSAGE = 'Table joins the tables that own the columns it selects, so name them, e.g. Table[:title, :username]. ' \
|
|
11
|
+
"For every column of the repository's table, name the table, e.g. Posts"
|
|
12
|
+
OPERATORS = %i[+ & < > <=> ^ * /].freeze
|
|
13
|
+
|
|
14
|
+
# A bare table on its own is every column, as a data expression: `Users[:username] + Posts`.
|
|
15
|
+
def self.expression(value) = value.is_a?(TableReference) ? value.to_expression : value
|
|
9
16
|
|
|
10
17
|
attr_reader :table, :filter, :negated
|
|
11
18
|
|
|
@@ -17,14 +24,16 @@ class LowData
|
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
# Each argument's type decides its meaning:
|
|
20
|
-
# Posts[:title] # A symbol is a column.
|
|
21
|
-
# Users[:username, Posts[:title]] # A data expression is a nested table
|
|
27
|
+
# Posts[:title] # A symbol is a column. Empty brackets select no columns.
|
|
28
|
+
# Users[:username, Posts[:title]] # A data expression is a nested table, and a bare table nests
|
|
29
|
+
# Users[:username, Posts] # every column.
|
|
22
30
|
# Reviews[:rating, author: Users[:username]] # A keyword with a data expression names a nested table.
|
|
23
31
|
# Posts[:id, headline: :title] # A keyword with a symbol renames a column.
|
|
24
32
|
def [](*arguments, **keywords)
|
|
25
|
-
|
|
33
|
+
check_negated!
|
|
26
34
|
return inverted(arguments:, keywords:) if @inverted
|
|
27
35
|
|
|
36
|
+
arguments = arguments.map { |argument| TableReference.expression(argument) }
|
|
28
37
|
expressions, columns = arguments.partition { |argument| argument.is_a?(DataExpression) }
|
|
29
38
|
check_columns!(columns:)
|
|
30
39
|
named, renames = named(keywords:, columns:)
|
|
@@ -60,65 +69,37 @@ class LowData
|
|
|
60
69
|
with(negated: !negated)
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
#
|
|
64
|
-
# Posts
|
|
65
|
-
#
|
|
66
|
-
# Posts
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
writer.insert(source: write_source, rows:)
|
|
70
|
-
end
|
|
72
|
+
# A bare table reads and joins as every column of the table:
|
|
73
|
+
# Posts # SELECT posts.* FROM posts
|
|
74
|
+
# Users[:username] + Posts # Each user with every column of their posts.
|
|
75
|
+
# Posts ^ -Col[:views] * 10 # The 10 most viewed posts.
|
|
76
|
+
def to_expression
|
|
77
|
+
raise ArgumentError, BARE_TABLE_MESSAGE if @inverted
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
check_negated!
|
|
80
|
+
DataExpression.new(node: source)
|
|
74
81
|
end
|
|
75
82
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
writer.update(source: write_source, values: assignments(columns:, value:))
|
|
83
|
+
OPERATORS.each do |operator|
|
|
84
|
+
define_method(operator) { |other| to_expression.public_send(operator, other) }
|
|
79
85
|
end
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
%i[to_a sql dataset].each do |name|
|
|
88
|
+
define_method(name) { |**options| to_expression.public_send(name, **options) }
|
|
83
89
|
end
|
|
84
90
|
|
|
85
91
|
def source
|
|
86
|
-
DataExpression::Source.new(table:, columns: [], filter:)
|
|
92
|
+
DataExpression::Source.new(table:, columns: [], filter:, bare: true)
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
private
|
|
90
96
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def writer
|
|
96
|
-
Writer.new(database: LowData.database)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def write_source
|
|
100
|
-
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't be written to" if negated
|
|
101
|
-
|
|
102
|
-
source
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# The columns in square brackets and the value assigned to them:
|
|
106
|
-
# Posts(id: 1)[:title] = 'New' # One column takes a value.
|
|
107
|
-
# Posts(id: 1)[:title, :body] = ['New', '...'] # Several columns take an array of as many values.
|
|
108
|
-
# Posts(id: 1)[] = { title: 'New' } # Empty brackets take a hash of columns and values.
|
|
109
|
-
def assignments(columns:, value:)
|
|
110
|
-
check_columns!(columns:)
|
|
111
|
-
return assigned_hash(value:) if columns.empty?
|
|
112
|
-
return { columns.first => value } if columns.one?
|
|
113
|
-
return columns.zip(value).to_h if value.is_a?(Array) && value.length == columns.length
|
|
114
|
-
|
|
115
|
-
raise ArgumentError, "#{columns.length} columns take #{columns.length} values, e.g. Posts(id: 1)[:title, :body] = ['New', '...']"
|
|
97
|
+
def check_negated!
|
|
98
|
+
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
|
|
116
99
|
end
|
|
117
100
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
raise ArgumentError, "Empty brackets take a hash of columns and values, e.g. Posts(id: 1)[] = { title: 'New' }"
|
|
101
|
+
def with(filter: @filter, negated: @negated)
|
|
102
|
+
self.class.new(table:, inverted: @inverted, filter:, negated:)
|
|
122
103
|
end
|
|
123
104
|
|
|
124
105
|
def related(key:, reference:, count: nil)
|
|
@@ -132,6 +113,7 @@ class LowData
|
|
|
132
113
|
|
|
133
114
|
# Returns the named nested tables and the renamed columns.
|
|
134
115
|
def named(keywords:, columns:)
|
|
116
|
+
keywords = keywords.transform_values { |value| TableReference.expression(value) }
|
|
135
117
|
named, others = keywords.partition { |_key, value| value.is_a?(DataExpression) }
|
|
136
118
|
renames, filters = others.partition { |_key, value| value.is_a?(Symbol) }
|
|
137
119
|
raise ArgumentError, format(FILTER_KEYWORD_MESSAGE, keywords: filters.to_h) if filters.any?
|
|
@@ -150,6 +132,8 @@ class LowData
|
|
|
150
132
|
end
|
|
151
133
|
|
|
152
134
|
def inverted(arguments:, keywords:)
|
|
135
|
+
raise ArgumentError, BARE_TABLE_MESSAGE if arguments.empty? && keywords.empty?
|
|
136
|
+
|
|
153
137
|
check_columns!(columns: arguments)
|
|
154
138
|
raise ArgumentError, 'Table[] takes column names only, and renames such as headline: :title' unless keywords.values.all?(Symbol)
|
|
155
139
|
|
data/lib/version.rb
CHANGED
data/lib/write_checks.rb
CHANGED
|
@@ -50,6 +50,19 @@ class LowData
|
|
|
50
50
|
raise ArgumentError, "Choose the #{source.table} to #{action} with round brackets, e.g. #{example}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# MySQL's ON DUPLICATE KEY UPDATE matches any unique key, so the upsert must match on one, and the row mustn't set
|
|
54
|
+
# every column of another, which could match a different row. An auto-increment id left out of the row can't match.
|
|
55
|
+
def check_mysql_keys!(table:, keys:, row:, target:)
|
|
56
|
+
matched, others = keys.partition { |key| key.sort == target.sort }
|
|
57
|
+
raise Error, "MySQL upserts match on a unique key, and #{target.join(', ')} isn't one of #{table}'s" if matched.empty?
|
|
58
|
+
|
|
59
|
+
clashing = others.find { |key| key.none? { |column| row[column].nil? } }
|
|
60
|
+
return unless clashing
|
|
61
|
+
|
|
62
|
+
raise Error, "MySQL matches an upsert on any unique key, and the row also sets #{clashing.join(', ')}, which could match " \
|
|
63
|
+
"another #{table} row. Leave #{clashing.join(', ')} out of the row, or match on it"
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
# Checked before the write, so a typo doesn't write a row and then fail to return it.
|
|
54
67
|
def check_returned!(source:, columns:)
|
|
55
68
|
missing = (source.columns + source.renames.values) - columns
|
data/lib/writer.rb
CHANGED
|
@@ -23,6 +23,8 @@ class LowData
|
|
|
23
23
|
def insert(source:, rows:)
|
|
24
24
|
check_rows!(rows:, operator: '<<')
|
|
25
25
|
check_insert_filter!(filter: source.filter)
|
|
26
|
+
check_returned!(source:, columns: @schema.columns(table: source.table))
|
|
27
|
+
return insert_all(source:, rows:) if source.no_columns? && rows.is_a?(Array) && rows.map(&:keys).uniq.one?
|
|
26
28
|
|
|
27
29
|
each_row(rows:) { |row| insert_row(source:, row:) }
|
|
28
30
|
end
|
|
@@ -31,6 +33,7 @@ class LowData
|
|
|
31
33
|
def upsert(source:, rows:)
|
|
32
34
|
check_rows!(rows:, operator: '<=')
|
|
33
35
|
check_upsert_filter!(filter: source.filter)
|
|
36
|
+
check_returned!(source:, columns: @schema.columns(table: source.table))
|
|
34
37
|
|
|
35
38
|
each_row(rows:) { |row| upsert_row(source:, row: source.filter.values.merge(row)) }
|
|
36
39
|
end
|
|
@@ -56,9 +59,17 @@ class LowData
|
|
|
56
59
|
@database.transaction { rows.map(&) }
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
# Empty brackets return nothing, so rows with the same columns go in one INSERT, as nothing needs matching back to
|
|
63
|
+
# them. Otherwise rows insert one by one, because RETURNING doesn't promise the inserted order, and a multi-row
|
|
64
|
+
# INSERT takes its columns from the first row, so a column missing from it would be dropped.
|
|
65
|
+
def insert_all(source:, rows:)
|
|
66
|
+
@database[source.table].multi_insert(rows)
|
|
67
|
+
rows.map { {} }
|
|
68
|
+
end
|
|
69
|
+
|
|
59
70
|
def insert_row(source:, row:)
|
|
60
71
|
dataset = @database[source.table]
|
|
61
|
-
return dataset.returning(*returned(source:)).insert(row).first if
|
|
72
|
+
return dataset.returning(*returned(source:)).insert(row).first if returning?(dataset:, source:)
|
|
62
73
|
|
|
63
74
|
id = dataset.insert(row)
|
|
64
75
|
read_back(source:, keys: inserted_keys(table: source.table, row:, id:))
|
|
@@ -72,18 +83,14 @@ class LowData
|
|
|
72
83
|
raise Error, "LowData can't upsert on #{@database.database_type}" unless dataset.respond_to?(:insert_conflict)
|
|
73
84
|
|
|
74
85
|
dataset = dataset.insert_conflict(target:, update: updates(row:, target:))
|
|
75
|
-
return dataset.returning(*returned(source:)).insert(row).first if
|
|
86
|
+
return dataset.returning(*returned(source:)).insert(row).first if returning?(dataset:, source:)
|
|
76
87
|
|
|
77
88
|
dataset.insert(row)
|
|
78
89
|
read_back(source:, keys: row.slice(*target))
|
|
79
90
|
end
|
|
80
91
|
|
|
81
|
-
# MySQL's ON DUPLICATE KEY UPDATE matches any unique key, so the named columns must be the table's only one.
|
|
82
92
|
def mysql_upsert(source:, row:, target:)
|
|
83
|
-
|
|
84
|
-
raise Error, "MySQL matches an upsert on any unique key, so #{target.join(', ')} must be #{source.table}'s only unique key"
|
|
85
|
-
end
|
|
86
|
-
|
|
93
|
+
check_mysql_keys!(table: source.table, keys: unique_keys(table: source.table), row:, target:)
|
|
87
94
|
@database[source.table].on_duplicate_key_update(*updates(row:, target:).keys).insert(row)
|
|
88
95
|
read_back(source:, keys: row.slice(*target))
|
|
89
96
|
end
|
|
@@ -115,15 +122,20 @@ class LowData
|
|
|
115
122
|
def returned(source:)
|
|
116
123
|
return @schema.primary_key(table: source.table) if source.every_column?
|
|
117
124
|
|
|
118
|
-
check_returned!(source:, columns: @schema.columns(table: source.table))
|
|
119
125
|
source.columns + source.renames.map { |name, column| Sequel.as(column, name) }
|
|
120
126
|
end
|
|
121
127
|
|
|
122
|
-
#
|
|
128
|
+
# Empty brackets return no columns, and databases without RETURNING read the row back by its keys.
|
|
123
129
|
def read_back(source:, keys:)
|
|
130
|
+
return {} if source.no_columns?
|
|
131
|
+
|
|
124
132
|
@database[source.table].where(keys).select(*returned(source:)).first
|
|
125
133
|
end
|
|
126
134
|
|
|
135
|
+
def returning?(dataset:, source:)
|
|
136
|
+
dataset.supports_returning?(:insert) && !source.no_columns?
|
|
137
|
+
end
|
|
138
|
+
|
|
127
139
|
def inserted_keys(table:, row:, id:)
|
|
128
140
|
primary_key = @schema.primary_key(table:)
|
|
129
141
|
return row if primary_key.empty?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lowdata
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
@@ -61,8 +61,9 @@ files:
|
|
|
61
61
|
- config/rubocop.yml
|
|
62
62
|
- lib/col.rb
|
|
63
63
|
- lib/data_expression.rb
|
|
64
|
+
- lib/data_expression/source.rb
|
|
65
|
+
- lib/data_expression/writes.rb
|
|
64
66
|
- lib/errors.rb
|
|
65
|
-
- lib/expression_writes.rb
|
|
66
67
|
- lib/filter_compiler.rb
|
|
67
68
|
- lib/inversion.rb
|
|
68
69
|
- lib/lowdata.rb
|
|
@@ -75,6 +76,7 @@ files:
|
|
|
75
76
|
- lib/rows.rb
|
|
76
77
|
- lib/schema.rb
|
|
77
78
|
- lib/table_reference.rb
|
|
79
|
+
- lib/table_reference/writes.rb
|
|
78
80
|
- lib/version.rb
|
|
79
81
|
- lib/write_checks.rb
|
|
80
82
|
- lib/writer.rb
|
|
File without changes
|