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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5902c4edccd0a49aec0a211a75c7dd3cbc277721155989ff0d4b9abbd84bb153
4
- data.tar.gz: cdb2e97726f61229d527d099c62f550e6d35f7dd984359d5a8bc6e62c0826b8d
3
+ metadata.gz: 00edcbe16f363ff5571130711b163c2b7a30f269b063237d9513f0d0d65b1ac9
4
+ data.tar.gz: b76da81116417a88cb6d440acfcd676aee2f9f53726c8b079551e4ab6df5279f
5
5
  SHA512:
6
- metadata.gz: 46deb79a98865325e79647f77a0f20a853146dfbcf6cede25a9e77433f8dc291f2ae09fe6d2d63d827b2a0e3e5f1c850d19c040f63cfad0b94b1c4515e93052b
7
- data.tar.gz: 87560d6313de8eca179b1ac1e6526258ca1fd0d4dc64b1fe24a1c5adb99fbc408bad0e876b9f381001f7d8079f045c5bed9cb1c4adef2ba365cbd6edcc961a2e
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
@@ -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[]]' if expressions.empty?
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 'expression_writes'
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
- raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil? && name.match?(/\A[A-Z]/)
76
- return super unless table_method?(name:)
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).build(node:)
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).build(node:)).map(&:sql)
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).select_append(*parents.uniq, *links)
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)
@@ -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
 
@@ -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
- ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users[] < Posts[]) ^ Col[:username]. Ruby binds ^ tighter ' \
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
- def compile(node:)
22
- return compile_ordered(node:) if node.is_a?(DataExpression::Ordered)
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
- dataset.select(*sources.flat_map { |source| selections(source:) })
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[] < Posts[] & Comments[]` runs `Posts[] & Comments[]` first ' \
120
- 'because & binds tighter than <. Add parentheses to join from the left: `(Users[] < Posts[]) & Comments[]`'
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[] * 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
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
- (outgoing + incoming).uniq - [table]
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
@@ -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`, waiting for its columns: `Users[:username]`.
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. No columns selects every 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
- raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
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
- # Writes run straight away. See Writer:
64
- # Posts << { title: 'Hello' } # Inserts, returning the new row's primary key.
65
- # Posts(slug: 'hello') <= { title: 'Hello' } # Upserts, matching on the round brackets.
66
- # Posts(id: 1)[:title] = 'New' # Updates the rows chosen by the round brackets.
67
- # Posts(id: 1).delete # Deletes them, returning how many.
68
- def <<(rows)
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
- def <=(rows)
73
- writer.upsert(source: write_source, rows:)
79
+ check_negated!
80
+ DataExpression.new(node: source)
74
81
  end
75
82
 
76
- # Ruby makes an assignment return its right-hand side, so `Posts(id: 1)[:title] = 'New'` returns 'New'.
77
- def []=(*columns, value)
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
- def delete
82
- writer.delete(source: write_source)
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 with(filter: @filter, negated: @negated)
92
- self.class.new(table:, inverted: @inverted, filter:, negated:)
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 assigned_hash(value:)
119
- return value if value.is_a?(Hash) && value.any?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LowData
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
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 dataset.supports_returning?(:insert)
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 dataset.supports_returning?(:insert)
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
- unless unique_keys(table: source.table).map(&:sort) == [target.sort]
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
- # Databases without RETURNING read the row back by its keys.
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.2.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