bespoke 0.2.0 → 0.2.1
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.
- data/bespoke.gemspec +3 -2
- data/lib/bespoke.rb +14 -0
- data/lib/bespoke/dsl.rb +2 -3
- data/lib/bespoke/export.rb +7 -0
- data/lib/bespoke/export/filter.rb +154 -0
- data/lib/bespoke/export/filter/function_call.rb +61 -0
- data/lib/bespoke/export/filter/identifier.rb +66 -0
- data/lib/bespoke/export/filter/select_template.rb +109 -0
- data/lib/bespoke/export/filter/string_literal.rb +16 -0
- data/lib/bespoke/join.rb +15 -8
- data/lib/bespoke/mustache.rb +17 -0
- data/lib/bespoke/projection.rb +31 -17
- data/lib/bespoke/template.rb +2 -2
- data/lib/bespoke/typed_field.rb +30 -0
- data/lib/bespoke/version.rb +1 -1
- data/spec/fixtures/sif.student_personal.xslt +1 -1
- data/spec/fixtures/sif.xslt.mustache +1 -1
- data/spec/lib/bespoke/export/filter/function_call_spec.rb +72 -0
- data/spec/lib/bespoke/export/filter/identifier_spec.rb +62 -0
- data/spec/lib/bespoke/export/filter/select_template_spec.rb +102 -0
- data/spec/lib/bespoke/export/filter_spec.rb +102 -0
- data/spec/lib/bespoke/export_spec.rb +6 -0
- data/spec/lib/bespoke/join_spec.rb +59 -0
- data/spec/lib/bespoke/projection_spec.rb +94 -0
- data/spec/{template_spec.rb → lib/bespoke/template_spec.rb} +3 -3
- data/spec/lib/bespoke/typed_field_spec.rb +101 -0
- data/spec/{xsltproc_spec.rb → lib/bespoke/xsltproc_spec.rb} +1 -1
- data/spec/spec_helper.rb +16 -3
- data/spec/support/fixture_helpers.rb +24 -0
- metadata +52 -12
- data/spec/join_spec.rb +0 -44
@@ -0,0 +1,16 @@
|
|
1
|
+
module Bespoke
|
2
|
+
class Export
|
3
|
+
class Filter
|
4
|
+
class StringLiteral
|
5
|
+
def self.match?(string)
|
6
|
+
string.start_with?("'") && string.end_with?("'")
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.extract_from(string)
|
10
|
+
unquoted_literal = string[1..-2]
|
11
|
+
Sequel.expr(unquoted_literal)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/bespoke/join.rb
CHANGED
@@ -11,7 +11,12 @@ module Bespoke
|
|
11
11
|
|
12
12
|
def database
|
13
13
|
return @database if @database
|
14
|
-
@database = Sequel.sqlite
|
14
|
+
@database = Sequel.sqlite(nil, after_connect: ->(connection) {
|
15
|
+
connection.create_function('concat_ws', -1) {|proxy, *args|
|
16
|
+
separator = args.pop
|
17
|
+
proxy.result = args.compact.join(separator)
|
18
|
+
}
|
19
|
+
})
|
15
20
|
create_tables
|
16
21
|
@database
|
17
22
|
end
|
@@ -27,9 +32,15 @@ module Bespoke
|
|
27
32
|
end
|
28
33
|
end
|
29
34
|
|
30
|
-
def
|
35
|
+
def [](sql)
|
31
36
|
database[sql]
|
32
37
|
end
|
38
|
+
alias_method :query, :[]
|
39
|
+
|
40
|
+
def projection_named(table_name)
|
41
|
+
table_symbol = database.tables.find{|t| t.to_s == table_name.to_s}
|
42
|
+
table_symbol.nil? ? nil : database[table_symbol]
|
43
|
+
end
|
33
44
|
|
34
45
|
protected
|
35
46
|
|
@@ -39,17 +50,13 @@ module Bespoke
|
|
39
50
|
|
40
51
|
def create_table(prj)
|
41
52
|
database.drop_table?(prj.name)
|
53
|
+
prj.ensure_primary_key!
|
42
54
|
database.create_table(prj.name) do
|
43
55
|
prj.fields.each do |field|
|
44
|
-
|
45
|
-
column :ref_id, :string, :primary_key => true
|
46
|
-
else
|
47
|
-
column field.name, field.type
|
48
|
-
end
|
56
|
+
column field.name, field.type, field.column_options
|
49
57
|
end
|
50
58
|
end
|
51
59
|
end
|
52
|
-
|
53
60
|
end
|
54
61
|
end
|
55
62
|
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'mustache'
|
2
|
+
|
3
|
+
if RUBY_VERSION >= '2.0'
|
4
|
+
class Mustache
|
5
|
+
TABLE_FOR_ESCAPE_HTML = {
|
6
|
+
'&' => '&',
|
7
|
+
'"' => '"',
|
8
|
+
'<' => '<',
|
9
|
+
'>' => '>',
|
10
|
+
}
|
11
|
+
REGEXP_FOR_ESCAPE_HTML = Regexp.new("[#{ TABLE_FOR_ESCAPE_HTML.keys.map{|k| Regexp.escape(k) }.join }]")
|
12
|
+
|
13
|
+
def escapeHTML(string)
|
14
|
+
string.gsub(REGEXP_FOR_ESCAPE_HTML, TABLE_FOR_ESCAPE_HTML)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/bespoke/projection.rb
CHANGED
@@ -1,28 +1,20 @@
|
|
1
|
-
|
2
|
-
class TypedField
|
3
|
-
attr_reader :name, :type, :xpath
|
4
|
-
|
5
|
-
# @name The name of the column or field
|
6
|
-
# @type The Sqlite3 type of the data
|
7
|
-
# @xpath The xpath query that retrieves the data from XML
|
8
|
-
def initialize(name, type, xpath)
|
9
|
-
@name = name
|
10
|
-
@type = type
|
11
|
-
@xpath = xpath
|
12
|
-
end
|
13
|
-
end
|
1
|
+
require_relative 'typed_field'
|
14
2
|
|
3
|
+
module Bespoke
|
15
4
|
class Projection
|
16
5
|
attr_reader :name, :fields, :xpath_root
|
17
6
|
|
18
|
-
|
7
|
+
class NoPrimaryKeyError < Bespoke::Error; end
|
8
|
+
|
9
|
+
def initialize(name, xpath_root=nil, &block)
|
19
10
|
@name = name.to_s
|
20
11
|
@xpath_root = xpath_root
|
21
12
|
@fields = []
|
13
|
+
handle_init_block(&block) if block_given?
|
22
14
|
end
|
23
15
|
|
24
|
-
def field(name, type=:string,
|
25
|
-
@fields << TypedField.new(name, type,
|
16
|
+
def field(name, type=:string, options_or_xpath = {})
|
17
|
+
@fields << TypedField.new(name, type, options_or_xpath)
|
26
18
|
end
|
27
19
|
|
28
20
|
def xpath_hash
|
@@ -31,5 +23,27 @@ module Bespoke
|
|
31
23
|
end.flatten(1)
|
32
24
|
Hash[*pairs]
|
33
25
|
end
|
26
|
+
|
27
|
+
def ensure_primary_key!
|
28
|
+
return if @fields.any?(&:primary_key?)
|
29
|
+
|
30
|
+
ref_id = @fields.find{|f| f.name == :ref_id }
|
31
|
+
if ref_id
|
32
|
+
warn 'Implicit primary key of ref_id detected, this feature is deprecated and will be removed soon.'
|
33
|
+
ref_id.make_primary_key!
|
34
|
+
else
|
35
|
+
raise NoPrimaryKeyError, "No primary key specified or implied for projection #{ self.name }"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def handle_init_block(&block)
|
42
|
+
if block.arity == 1
|
43
|
+
block.call(self)
|
44
|
+
else
|
45
|
+
instance_exec(&block)
|
46
|
+
end
|
47
|
+
end
|
34
48
|
end
|
35
|
-
end
|
49
|
+
end
|
data/lib/bespoke/template.rb
CHANGED
@@ -0,0 +1,30 @@
|
|
1
|
+
module Bespoke
|
2
|
+
class TypedField
|
3
|
+
attr_reader :name, :type, :column_options, :xpath
|
4
|
+
|
5
|
+
# @name The name of the column or field
|
6
|
+
# @type The Sqlite3 type of the data
|
7
|
+
# @options_or_xpath The options to be supplied to Sequel when generating the column
|
8
|
+
# along with the xpath used to extract the data from XML
|
9
|
+
def initialize(name, type, options_or_xpath = {})
|
10
|
+
@name = name
|
11
|
+
@type = type
|
12
|
+
if options_or_xpath.is_a? Hash
|
13
|
+
@xpath = options_or_xpath.delete(:xpath)
|
14
|
+
@column_options = options_or_xpath
|
15
|
+
else
|
16
|
+
@xpath = options_or_xpath
|
17
|
+
@column_options = {}
|
18
|
+
warn "Passing a string or array as the xpath is deprecated, please supply an options hash with an xpath key at: #{ caller[1] }"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def make_primary_key!
|
23
|
+
column_options[:primary_key] = true
|
24
|
+
end
|
25
|
+
|
26
|
+
def primary_key?
|
27
|
+
!!column_options[:primary_key]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/bespoke/version.rb
CHANGED
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Bespoke
|
4
|
+
class Export::Filter
|
5
|
+
describe FunctionCall do
|
6
|
+
describe '#function' do
|
7
|
+
it 'must correctly identify the function specified when no args are present' do
|
8
|
+
call = FunctionCall.new('rand()')
|
9
|
+
expect(call.function).to eq 'rand'
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'must correctly identify the function specified when an argument is being passed' do
|
13
|
+
call = FunctionCall.new("foo_bar(0)")
|
14
|
+
expect(call.function).to eq 'foo_bar'
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'must correctly identify the function specified when multiple arguments are being supplied' do
|
18
|
+
call = FunctionCall.new("foo_bar(0, 2, foo)")
|
19
|
+
expect(call.function).to eq 'foo_bar'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#arguments' do
|
24
|
+
it 'must return an empty array when no arguments are specified for the function being called' do
|
25
|
+
call = FunctionCall.new('rand()')
|
26
|
+
expect(call.arguments).to eq []
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'must return an array with a single string when a single argument is supplied' do
|
30
|
+
call = FunctionCall.new("foo_bar(0)")
|
31
|
+
expect(call.arguments).to eq ['0']
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'must return an array with the specified values when multiple values are supplied' do
|
35
|
+
call = FunctionCall.new("foo_bar(0, foo, bar)")
|
36
|
+
expect(call.arguments).to eq %w{0 foo bar}
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'must reutrn the correct values when the whitespace is inconsistent' do
|
40
|
+
call = FunctionCall.new("foo_bar(\t 0, foo,bar )")
|
41
|
+
expect(call.arguments).to eq %w{0 foo bar}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#extract' do
|
46
|
+
it 'must return a Sequel function node for most inputs' do
|
47
|
+
call = FunctionCall.new("foo_bar(0, foo, bar)")
|
48
|
+
extracted_call = call.extract
|
49
|
+
expect(extracted_call).to be_a Sequel::SQL::Function
|
50
|
+
expect(extracted_call.name).to eq 'FOO_BAR'
|
51
|
+
expect(extracted_call.args).to eq %w{0 foo bar}
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'must specially handle a function named random_str' do
|
55
|
+
call = FunctionCall.new("random_str(4)")
|
56
|
+
extracted_call = call.extract
|
57
|
+
expect(extracted_call).to be_a Sequel::LiteralString
|
58
|
+
expect(extracted_call.to_s).to eq 'LOWER(HEX(RANDOMBLOB(4)))'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "custom functions" do
|
63
|
+
it "can be created" do
|
64
|
+
call = FunctionCall.new("myfn('hi')", :myfn => ->(args) { "LOWER(#{args.first})" })
|
65
|
+
extracted_call = call.extract
|
66
|
+
expect(extracted_call).to be_a Sequel::LiteralString
|
67
|
+
expect(extracted_call.to_s).to eq "LOWER('hi')"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'bespoke/export/filter/identifier'
|
3
|
+
|
4
|
+
module Bespoke
|
5
|
+
class Export::Filter
|
6
|
+
describe Identifier do
|
7
|
+
describe '#sequel_identifier' do
|
8
|
+
it 'must return an unqualified identifer with the right content for an unqualified column' do
|
9
|
+
identifier = Identifier.new('id')
|
10
|
+
sequel_identifier = identifier.sequel_identifier
|
11
|
+
expect(sequel_identifier).to be_a Sequel::SQL::Identifier
|
12
|
+
expect(sequel_identifier.value).to eq 'id'
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'must return a qualified identifier with the right conent for a qualified column' do
|
16
|
+
identifier = Identifier.new('students.id')
|
17
|
+
sequel_identifier = identifier.sequel_identifier
|
18
|
+
expect(sequel_identifier).to be_a Sequel::SQL::QualifiedIdentifier
|
19
|
+
expect(sequel_identifier.table).to eq 'students'
|
20
|
+
expect(sequel_identifier.column).to eq 'id'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '#parts' do
|
25
|
+
it 'must correctly handle identifiers surrounded by arbitrary whitespace' do
|
26
|
+
identifier = Identifier.new("\t students.id ")
|
27
|
+
expect(identifier.parts).to eq({table: 'students', column: 'id'})
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'must take an unqualified unquoted column identifier and return the correct values' do
|
31
|
+
identifier = Identifier.new('id')
|
32
|
+
expect(identifier.parts).to eq({table: nil, column: 'id'})
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'must take a qualified unquoted column identifier and return the correct values' do
|
36
|
+
identifier = Identifier.new('students.id')
|
37
|
+
expect(identifier.parts).to eq({table: 'students', column: 'id'})
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'must take an unqualified quoted column identifier and return the correct values' do
|
41
|
+
identifier = Identifier.new('"name.first_name"')
|
42
|
+
expect(identifier.parts).to eq({table: nil, column: 'name.first_name'})
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'must take a qualified column identifier with the column name quoted and return the correct values' do
|
46
|
+
identifier = Identifier.new('students."name.first_name"')
|
47
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'must take a qualified column identifier with the table name quoted and return the correct values' do
|
51
|
+
identifier = Identifier.new('"students".name')
|
52
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name'})
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'must take a qualified, fully quoted column identifier and return the correct values' do
|
56
|
+
identifier = Identifier.new('"students"."name.first_name"')
|
57
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Bespoke
|
4
|
+
class Export::Filter
|
5
|
+
describe SelectTemplate do
|
6
|
+
let(:join) { basic_join }
|
7
|
+
let(:db) { join.database }
|
8
|
+
let(:table) { db[:two] }
|
9
|
+
let(:template) { SelectTemplate.new(:course_id, '') }
|
10
|
+
|
11
|
+
describe '#compile' do
|
12
|
+
let(:result) { template.compile }
|
13
|
+
let(:sql) { table.select(result).sql }
|
14
|
+
|
15
|
+
it 'must build an aliased expression with the correct alias' do
|
16
|
+
expect(result.aliaz).to eq :course_id
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'must interpret a symbol as a column specification' do
|
20
|
+
template.template_string = :id
|
21
|
+
expect(sql).to include %q[`id` AS 'course_id']
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'must interpret a symbol with double underscores as a qualified column specification' do
|
25
|
+
template.template_string = :sections__id
|
26
|
+
expect(sql).to include %q[`sections`.`id` AS 'course_id']
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'must interpret unqualified templates correctly' do
|
30
|
+
template.template_string = '<id>'
|
31
|
+
expect(sql).to include %q[`id` AS 'course_id']
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'must interpret qualified templates correctly' do
|
35
|
+
template.template_string = '<sections.id>'
|
36
|
+
expect(sql).to include %q[`sections`.`id` AS 'course_id']
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'must interpret a template with multiple values as neeing to be caalesced' do
|
40
|
+
template.template_string = '<id | foo>'
|
41
|
+
expect(sql).to include %q[COALESCE(`id`, `foo`) AS 'course_id']
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'must interepret a template in single quotes as a literal value' do
|
45
|
+
template.template_string = "<'foo'>"
|
46
|
+
expect(sql).to include %q['foo' AS 'course_id']
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'must coalesce a identifer and a literal string correctly' do
|
50
|
+
template.template_string = "<id | 'foo'>"
|
51
|
+
expect(sql).to include %q[COALESCE(`id`, 'foo') AS 'course_id']
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'must interepret a default value of random_str(\d+) correctly' do
|
55
|
+
template.template_string = '<courses.enrollment_count | random_str(6)>'
|
56
|
+
expect(sql).to include %q[COALESCE(`courses`.`enrollment_count`, LOWER(HEX(RANDOMBLOB(6)))) AS 'course_id']
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'must interpret a template consisting of a column and a prepended string correctly' do
|
60
|
+
template.template_string = 'course_<courses.id>'
|
61
|
+
expect(sql).to include %q[('course_' || `courses`.`id`) AS 'course_id']
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'must interpret a template consisting of a columna and an appended string correctly' do
|
65
|
+
template.template_string = '<students.student_username>@foo.k12.mo.us'
|
66
|
+
expect(sql).to include %q[(`students`.`student_username` || '@foo.k12.mo.us') AS 'course_id']
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'must interpret complex strings with column templates interpolated inside them' do
|
70
|
+
template.template_string = 'course_<courses.id>_record'
|
71
|
+
expect(sql).to include %q[('course_' || `courses`.`id` || '_record') AS 'course_id']
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'must interpret 2 element joined array selecte template strings correctly' do
|
75
|
+
template.template_string = '<section_id>-<term_id>'
|
76
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, '-') AS 'course_id']
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'must interpret joined array select template strings correctly' do
|
80
|
+
template.template_string = '<section_id>-<term_id>-<staff_id>'
|
81
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '-') AS 'course_id']
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'must extract the correct separator from joined array template strings' do
|
85
|
+
template.template_string = '<section_id>=+=<term_id>=+=<staff_id>'
|
86
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '=+=') AS 'course_id']
|
87
|
+
end
|
88
|
+
|
89
|
+
context "with custom function" do
|
90
|
+
let(:template) {
|
91
|
+
SelectTemplate.new(:course_id, '', :myfn => ->(args) { "MYFN()" })
|
92
|
+
}
|
93
|
+
|
94
|
+
it 'does it' do
|
95
|
+
template.template_string = '<myfn(5)>'
|
96
|
+
expect(sql).to include %q[MYFN()]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|