mpql 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: efb2efbefe64a0440d1fc21620820791b9c8dbf6298b6cc55b22cc70d54f4a55
4
+ data.tar.gz: a4dc264a5b02f0b7d6ee6e07f754bfebd4ef18b205cbb27a199f6238d49dcdb9
5
+ SHA512:
6
+ metadata.gz: bbeb54de0e804fffe71383d78edd38d44e28070e8f1b0b89ae309ca927bf76ab9e564819a0e498e19d4ce73f9813567172488c89a738d0b361a1143a921189d6
7
+ data.tar.gz: 2441597897c95693cfe5b7a2fb41ffe628cf72afb3c9ec1d195af03b1dc7fb62a1650009f9d689a3da29cf14900c3cf49d840d900f6b81c160b405dc0e8961a6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 tomorrowkey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # mpql
2
+
3
+ A command-line tool to execute MixPanel Query API requests. Designed for both human users and AI agents.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ gem install mpql
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Create `~/.mpql.yml`:
14
+
15
+ ```yaml
16
+ username: your-service-account-username
17
+ secret: your-service-account-secret
18
+ ```
19
+
20
+ Or use environment variables:
21
+
22
+ ```
23
+ MIXPANEL_USERNAME=your-service-account-username
24
+ MIXPANEL_SECRET=your-service-account-secret
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Segmentation
30
+
31
+ ```
32
+ mpql segmentation --project-id 123456 --event "Signed Up" --from 7d --to today
33
+ mpql segmentation --project-id 123456 --event "Page View" --from 2026-03-01 --to 2026-03-09 --on "properties.$browser" --unit day
34
+ mpql segmentation --project-id 123456 --event "Purchase" --from 1m --to today --type unique --format tsv
35
+ ```
36
+
37
+ ### Funnels
38
+
39
+ ```
40
+ mpql funnels --project-id 123456 --funnel-id 12345 --from 7d --to today
41
+ mpql funnels --project-id 123456 --funnel-id 12345 --from 2026-03-01 --to 2026-03-09 --on "properties.$os"
42
+ ```
43
+
44
+ ### Retention
45
+
46
+ ```
47
+ mpql retention --project-id 123456 --from 30d --to today
48
+ mpql retention --project-id 123456 --from 2026-02-01 --to 2026-03-09 --born-event "Signed Up" --event "Login"
49
+ ```
50
+
51
+ ### Insights
52
+
53
+ ```
54
+ mpql insights --project-id 123456 --bookmark-id 67890
55
+ ```
56
+
57
+ ### Cohorts
58
+
59
+ ```
60
+ mpql cohorts --project-id 123456
61
+ mpql cohorts --project-id 123456 --format table
62
+ ```
63
+
64
+ ### Export (Raw Event Data)
65
+
66
+ ```
67
+ mpql export --project-id 123456 --event "ScreenView" --from 7d --to today
68
+ mpql export --project-id 123456 --event "Signed Up" --from 2026-03-01 --to 2026-03-09
69
+ ```
70
+
71
+ ### Engage (User Profiles)
72
+
73
+ ```
74
+ mpql engage --project-id 123456
75
+ mpql engage --project-id 123456 --where 'properties["$email"] == "test@example.com"'
76
+ mpql engage --project-id 123456 --distinct-id "user123"
77
+ ```
78
+
79
+ ### Date Formats
80
+
81
+ - `yyyy-mm-dd` (e.g., `2026-03-01`)
82
+ - `today`, `yesterday`
83
+ - `Nd` - N days ago (e.g., `7d`)
84
+ - `Nw` - N weeks ago (e.g., `2w`)
85
+ - `Nm` - N months ago (e.g., `1m`)
86
+
87
+ ### Output Formats
88
+
89
+ - `--format json` (default) - JSON output
90
+ - `--format tsv` - Tab-separated values
91
+ - `--format table` - ASCII table
92
+ - `--raw` - Raw JSON without pretty-printing
93
+
94
+ ### Global Options
95
+
96
+ - `--project-id` - MixPanel project ID (required)
97
+ - `--region` - MixPanel region (`us`, `eu`, `in`). Defaults to `us`
98
+
99
+ ## Release
100
+
101
+ 1. Update the version number in `lib/mpql/version.rb`
102
+ 2. Commit the changes
103
+ 3. Run `rake release`
104
+
105
+ This will create a git tag, build the gem, and push it to [rubygems.org](https://rubygems.org).
106
+
107
+ ## License
108
+
109
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
data/exe/mpql ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "mpql"
5
+ require "mpql/cli"
6
+
7
+ Mpql::CLI.start(ARGV)
data/lib/mpql/cli.rb ADDED
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require_relative "formatters/json_formatter"
6
+ require_relative "formatters/tsv_formatter"
7
+ require_relative "formatters/table_formatter"
8
+
9
+ module Mpql
10
+ class CLI < Thor
11
+ # Exit with non-zero status on errors
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ class_option :format, type: :string, default: "json", enum: %w[json tsv table],
17
+ desc: "Output format (json, tsv, table)"
18
+ class_option :raw, type: :boolean, default: false,
19
+ desc: "Output raw JSON without pretty-printing"
20
+ class_option :region, type: :string, enum: %w[us eu in],
21
+ desc: "MixPanel region (us, eu, in)"
22
+ class_option :project_id, type: :string, required: true,
23
+ desc: "MixPanel project ID"
24
+
25
+ desc "segmentation", "Query segmentation data for an event"
26
+ long_desc <<~LONGDESC
27
+ Query segmentation data for a specific event, segmented by a property.
28
+
29
+ Examples:
30
+
31
+ mpql segmentation --project-id 123456 --event "Signed Up" --from 7d --to today
32
+
33
+ mpql segmentation --project-id 123456 --event "Page View" --from 2026-03-01 --to 2026-03-09 --on "properties.$browser" --unit day
34
+
35
+ mpql segmentation --project-id 123456 --event "Purchase" --from 1m --to today --type unique --format tsv
36
+
37
+ Date formats: yyyy-mm-dd, today, yesterday, Nd (days ago), Nw (weeks ago), Nm (months ago)
38
+ LONGDESC
39
+ option :event, type: :string, required: true, desc: "Event name"
40
+ option :from, type: :string, required: true, desc: "Start date"
41
+ option :to, type: :string, required: true, desc: "End date"
42
+ option :on, type: :string, desc: "Property expression to segment by"
43
+ option :unit, type: :string, enum: %w[minute hour day month], desc: "Time unit"
44
+ option :where, type: :string, desc: "Filter expression"
45
+ option :type, type: :string, enum: %w[general unique average], desc: "Analysis type"
46
+ option :limit, type: :numeric, desc: "Max number of property values to return"
47
+ def segmentation
48
+ result = client.segmentation(
49
+ event: options[:event],
50
+ from_date: parse_date(options[:from]),
51
+ to_date: parse_date(options[:to]),
52
+ **optional_params(:on, :unit, :where, :type, :limit)
53
+ )
54
+ output(result)
55
+ rescue Mpql::Error => e
56
+ error_exit(e)
57
+ end
58
+
59
+ desc "funnels", "Query funnel analysis data"
60
+ long_desc <<~LONGDESC
61
+ Query data for a saved funnel.
62
+
63
+ Examples:
64
+
65
+ mpql funnels --project-id 123456 --funnel-id 12345 --from 7d --to today
66
+
67
+ mpql funnels --project-id 123456 --funnel-id 12345 --from 2026-03-01 --to 2026-03-09 --on "properties.$os"
68
+ LONGDESC
69
+ option :funnel_id, type: :numeric, required: true, desc: "Funnel ID"
70
+ option :from, type: :string, required: true, desc: "Start date"
71
+ option :to, type: :string, required: true, desc: "End date"
72
+ option :on, type: :string, desc: "Property expression to segment by"
73
+ option :where, type: :string, desc: "Filter expression"
74
+ option :limit, type: :numeric, desc: "Max number of property values to return"
75
+ def funnels
76
+ result = client.funnels(
77
+ funnel_id: options[:funnel_id],
78
+ from_date: parse_date(options[:from]),
79
+ to_date: parse_date(options[:to]),
80
+ **optional_params(:on, :where, :limit)
81
+ )
82
+ output(result)
83
+ rescue Mpql::Error => e
84
+ error_exit(e)
85
+ end
86
+
87
+ desc "retention", "Query retention analysis data"
88
+ long_desc <<~LONGDESC
89
+ Query retention analysis data.
90
+
91
+ Examples:
92
+
93
+ mpql retention --project-id 123456 --from 30d --to today
94
+
95
+ mpql retention --project-id 123456 --from 2026-02-01 --to 2026-03-09 --born-event "Signed Up" --event "Login"
96
+ LONGDESC
97
+ option :from, type: :string, required: true, desc: "Start date"
98
+ option :to, type: :string, required: true, desc: "End date"
99
+ option :born_event, type: :string, desc: "Birth event name"
100
+ option :event, type: :string, desc: "Return event name"
101
+ option :retention_type, type: :string, enum: %w[birth compounded], desc: "Retention type"
102
+ option :on, type: :string, desc: "Property expression to segment by"
103
+ option :unit, type: :string, enum: %w[day week month], desc: "Bucket unit"
104
+ option :interval, type: :numeric, desc: "Bucket interval"
105
+ def retention
106
+ result = client.retention(
107
+ from_date: parse_date(options[:from]),
108
+ to_date: parse_date(options[:to]),
109
+ **optional_params(:born_event, :event, :retention_type, :on, :unit, :interval)
110
+ )
111
+ output(result)
112
+ rescue Mpql::Error => e
113
+ error_exit(e)
114
+ end
115
+
116
+ desc "insights", "Query a saved Insights report"
117
+ long_desc <<~LONGDESC
118
+ Query data from a saved Insights report by its bookmark ID.
119
+
120
+ Examples:
121
+
122
+ mpql insights --project-id 123456 --bookmark-id 67890
123
+ LONGDESC
124
+ option :bookmark_id, type: :numeric, required: true, desc: "Insights report bookmark ID"
125
+ def insights
126
+ result = client.insights(
127
+ bookmark_id: options[:bookmark_id],
128
+ **optional_params
129
+ )
130
+ output(result)
131
+ rescue Mpql::Error => e
132
+ error_exit(e)
133
+ end
134
+
135
+ desc "cohorts", "List saved cohorts"
136
+ long_desc <<~LONGDESC
137
+ List all saved cohorts in the project.
138
+
139
+ Examples:
140
+
141
+ mpql cohorts --project-id 123456
142
+
143
+ mpql cohorts --project-id 123456 --format table
144
+ LONGDESC
145
+ def cohorts
146
+ result = client.cohorts(**optional_params)
147
+ output(result)
148
+ rescue Mpql::Error => e
149
+ error_exit(e)
150
+ end
151
+
152
+ desc "engage", "Query user profiles"
153
+ long_desc <<~LONGDESC
154
+ Query user profiles using filter expressions.
155
+
156
+ Examples:
157
+
158
+ mpql engage --project-id 123456
159
+
160
+ mpql engage --project-id 123456 --where 'properties["$email"] == "test@example.com"'
161
+
162
+ mpql engage --project-id 123456 --distinct-id "user123"
163
+ LONGDESC
164
+ option :where, type: :string, desc: "Filter expression"
165
+ option :distinct_id, type: :string, desc: "Specific user distinct ID"
166
+ option :page, type: :numeric, desc: "Result page number (starting from 0)"
167
+ option :session_id, type: :string, desc: "Session ID for pagination"
168
+ def engage
169
+ result = client.engage(**optional_params(:where, :distinct_id, :page, :session_id))
170
+ output(result)
171
+ rescue Mpql::Error => e
172
+ error_exit(e)
173
+ end
174
+
175
+ desc "export", "Export raw event data"
176
+ long_desc <<~LONGDESC
177
+ Export raw event data for a specific event.
178
+
179
+ Examples:
180
+
181
+ mpql export --project-id 123456 --event "ScreenView" --from 7d --to today
182
+
183
+ mpql export --project-id 123456 --event "Signed Up" --from 2026-03-01 --to 2026-03-09
184
+ LONGDESC
185
+ option :event, type: :string, required: true, desc: "Event name"
186
+ option :from, type: :string, required: true, desc: "Start date"
187
+ option :to, type: :string, required: true, desc: "End date"
188
+ def export
189
+ result = client.export(
190
+ event: options[:event],
191
+ from_date: parse_date(options[:from]),
192
+ to_date: parse_date(options[:to])
193
+ )
194
+ output(result)
195
+ rescue Mpql::Error => e
196
+ error_exit(e)
197
+ end
198
+
199
+ desc "version", "Show mpql version"
200
+ def version
201
+ puts Mpql::VERSION
202
+ end
203
+
204
+ private
205
+
206
+ def client
207
+ apply_overrides
208
+ @client ||= Client.new
209
+ end
210
+
211
+ def apply_overrides
212
+ config = Mpql.configuration
213
+ config.region = options[:region] if options[:region]
214
+ config.project_id = options[:project_id]
215
+ end
216
+
217
+ def parse_date(value)
218
+ DateParser.parse(value)
219
+ end
220
+
221
+ def output(data)
222
+ formatter = case options[:format]
223
+ when "tsv"
224
+ Formatters::TsvFormatter.new(data)
225
+ when "table"
226
+ Formatters::TableFormatter.new(data)
227
+ else
228
+ Formatters::JsonFormatter.new(data, raw: options[:raw])
229
+ end
230
+ $stdout.puts formatter.render
231
+ end
232
+
233
+ def error_exit(error)
234
+ exit_code = case error
235
+ when AuthenticationError then 1
236
+ when ApiError then 2
237
+ when InputError then 3
238
+ when ConfigurationError then 4
239
+ else 1
240
+ end
241
+
242
+ error_data = { error: error.class.name, message: error.message }
243
+ $stdout.puts JSON.generate(error_data)
244
+ exit exit_code
245
+ end
246
+
247
+ def optional_params(*keys)
248
+ return {} if keys.empty?
249
+
250
+ keys.each_with_object({}) do |key, hash|
251
+ hash[key] = options[key] if options[key]
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Mpql
8
+ class Client
9
+ def initialize(config = Mpql.configuration)
10
+ @config = config
11
+ end
12
+
13
+ def segmentation(event:, from_date:, to_date:, **options)
14
+ params = { event: event, from_date: from_date, to_date: to_date }.merge(options)
15
+ get("/api/query/segmentation", params)
16
+ end
17
+
18
+ def funnels(funnel_id:, from_date:, to_date:, **options)
19
+ params = { funnel_id: funnel_id, from_date: from_date, to_date: to_date }.merge(options)
20
+ get("/api/query/funnels", params)
21
+ end
22
+
23
+ def retention(from_date:, to_date:, **options)
24
+ params = { from_date: from_date, to_date: to_date }.merge(options)
25
+ get("/api/query/retention", params)
26
+ end
27
+
28
+ def insights(bookmark_id:, **options)
29
+ params = { bookmark_id: bookmark_id }.merge(options)
30
+ get("/api/query/insights", params)
31
+ end
32
+
33
+ def cohorts(**options)
34
+ post("/api/query/cohorts/list", options)
35
+ end
36
+
37
+ def engage(**options)
38
+ post("/api/query/engage", options)
39
+ end
40
+
41
+ def export(from_date:, to_date:, event:, **options)
42
+ params = { from_date: from_date, to_date: to_date, event: JSON.generate([event]) }.merge(options)
43
+ get_jsonl("/api/2.0/export", params)
44
+ end
45
+
46
+ private
47
+
48
+ def get(path, params)
49
+ @config.validate!
50
+ params[:project_id] = @config.project_id
51
+ uri = build_uri(path, params)
52
+ request = Net::HTTP::Get.new(uri)
53
+ execute(uri, request)
54
+ end
55
+
56
+ def get_jsonl(path, params)
57
+ @config.validate!
58
+ params[:project_id] = @config.project_id
59
+ uri = build_uri(path, params, base_url: @config.data_base_url)
60
+ request = Net::HTTP::Get.new(uri)
61
+ execute(uri, request, jsonl: true)
62
+ end
63
+
64
+ def post(path, params)
65
+ @config.validate!
66
+ params[:project_id] = @config.project_id
67
+ uri = build_uri(path)
68
+ request = Net::HTTP::Post.new(uri)
69
+ request.set_form_data(params)
70
+ execute(uri, request)
71
+ end
72
+
73
+ def execute(uri, request, jsonl: false)
74
+ request.basic_auth(@config.username, @config.secret)
75
+
76
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
77
+ http.request(request)
78
+ end
79
+
80
+ handle_response(response, jsonl: jsonl)
81
+ end
82
+
83
+ def handle_response(response, jsonl: false)
84
+ case response.code.to_i
85
+ when 200
86
+ if jsonl
87
+ parse_jsonl(response.body)
88
+ else
89
+ JSON.parse(response.body)
90
+ end
91
+ when 401, 403
92
+ raise AuthenticationError, build_error_message(response)
93
+ else
94
+ raise ApiError, build_error_message(response)
95
+ end
96
+ end
97
+
98
+ def parse_jsonl(body)
99
+ body.each_line.filter_map do |line|
100
+ line = line.strip
101
+ next if line.empty?
102
+
103
+ JSON.parse(line)
104
+ end
105
+ end
106
+
107
+ def build_error_message(response)
108
+ body = begin
109
+ JSON.parse(response.body)
110
+ rescue JSON::ParserError
111
+ { "error" => response.body }
112
+ end
113
+
114
+ message = body["error"] || body["message"] || response.body
115
+ "HTTP #{response.code}: #{message}"
116
+ end
117
+
118
+ def build_uri(path, params = {}, base_url: @config.base_url)
119
+ uri = URI("#{base_url}#{path}")
120
+ uri.query = URI.encode_www_form(params.compact) unless params.empty?
121
+ uri
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Mpql
6
+ class Configuration
7
+ REGIONS = {
8
+ "us" => "https://mixpanel.com",
9
+ "eu" => "https://eu.mixpanel.com",
10
+ "in" => "https://in.mixpanel.com"
11
+ }.freeze
12
+
13
+ DATA_REGIONS = {
14
+ "us" => "https://data.mixpanel.com",
15
+ "eu" => "https://data-eu.mixpanel.com",
16
+ "in" => "https://data-in.mixpanel.com"
17
+ }.freeze
18
+
19
+ attr_accessor :username, :secret, :project_id, :region
20
+
21
+ def initialize
22
+ @username = nil
23
+ @secret = nil
24
+ @project_id = nil
25
+ @region = "us"
26
+
27
+ load_config_file
28
+ load_env
29
+ end
30
+
31
+ def base_url
32
+ REGIONS.fetch(@region, REGIONS["us"])
33
+ end
34
+
35
+ def data_base_url
36
+ DATA_REGIONS.fetch(@region, DATA_REGIONS["us"])
37
+ end
38
+
39
+ def validate!
40
+ errors = []
41
+ errors << "MIXPANEL_USERNAME is not set" unless @username
42
+ errors << "MIXPANEL_SECRET is not set" unless @secret
43
+ errors << "project_id is not set" unless @project_id
44
+
45
+ raise ConfigurationError, errors.join(", ") unless errors.empty?
46
+ end
47
+
48
+ private
49
+
50
+ def load_config_file
51
+ config_path = File.expand_path("~/.mpql.yml")
52
+ return unless File.exist?(config_path)
53
+
54
+ config = YAML.safe_load_file(config_path, permitted_classes: [Symbol])
55
+ return unless config.is_a?(Hash)
56
+
57
+ @username = config["username"] || config[:username]
58
+ @secret = config["secret"] || config[:secret]
59
+ end
60
+
61
+ def load_env
62
+ @username = ENV["MIXPANEL_USERNAME"] if ENV["MIXPANEL_USERNAME"]
63
+ @secret = ENV["MIXPANEL_SECRET"] if ENV["MIXPANEL_SECRET"]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mpql
6
+ module DateParser
7
+ module_function
8
+
9
+ # Parse a date string into yyyy-mm-dd format.
10
+ #
11
+ # Supported formats:
12
+ # - "today" -> today's date
13
+ # - "yesterday" -> yesterday's date
14
+ # - "Nd" -> N days ago (e.g., "7d")
15
+ # - "Nw" -> N weeks ago (e.g., "3w")
16
+ # - "Nm" -> N months ago (e.g., "1m")
17
+ # - "yyyy-mm-dd" -> passed through as-is
18
+ def parse(input)
19
+ case input.to_s.strip.downcase
20
+ when "today"
21
+ Date.today.strftime("%Y-%m-%d")
22
+ when "yesterday"
23
+ (Date.today - 1).strftime("%Y-%m-%d")
24
+ when /\A(\d+)d\z/
25
+ (Date.today - ::Regexp.last_match(1).to_i).strftime("%Y-%m-%d")
26
+ when /\A(\d+)w\z/
27
+ (Date.today - (::Regexp.last_match(1).to_i * 7)).strftime("%Y-%m-%d")
28
+ when /\A(\d+)m\z/
29
+ (Date.today << ::Regexp.last_match(1).to_i).strftime("%Y-%m-%d")
30
+ when /\A\d{4}-\d{2}-\d{2}\z/
31
+ Date.parse(input.strip).strftime("%Y-%m-%d")
32
+ else
33
+ raise InputError, "Invalid date format: '#{input}'. Use yyyy-mm-dd, today, yesterday, Nd, Nw, or Nm"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mpql
6
+ module Formatters
7
+ class JsonFormatter
8
+ def initialize(data, raw: false)
9
+ @data = data
10
+ @raw = raw
11
+ end
12
+
13
+ def render
14
+ if @raw
15
+ JSON.generate(@data)
16
+ else
17
+ JSON.pretty_generate(@data)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mpql
4
+ module Formatters
5
+ class RowBuilder
6
+ attr_reader :headers, :rows
7
+
8
+ def initialize(data)
9
+ @headers, @rows = build_table(data)
10
+ end
11
+
12
+ private
13
+
14
+ def build_table(data)
15
+ case data
16
+ when Array
17
+ build_from_array(data)
18
+ when Hash
19
+ build_from_hash(data)
20
+ else
21
+ [nil, [[data]]]
22
+ end
23
+ end
24
+
25
+ def build_from_array(data)
26
+ return [nil, []] if data.empty?
27
+
28
+ if data.all?(Hash)
29
+ headers = data.flat_map(&:keys).uniq
30
+ rows = data.map { |item| headers.map { |h| item[h] } }
31
+ [headers, rows]
32
+ else
33
+ [nil, data.map { |item| [item] }]
34
+ end
35
+ end
36
+
37
+ def build_from_hash(data)
38
+ if segmentation_data?(data)
39
+ build_from_segmentation(data)
40
+ elsif engage_data?(data)
41
+ build_from_engage(data)
42
+ elsif funnel_data?(data)
43
+ build_from_funnel(data)
44
+ elsif retention_data?(data)
45
+ build_from_retention(data)
46
+ else
47
+ build_from_flat_hash(data)
48
+ end
49
+ end
50
+
51
+ def segmentation_data?(data)
52
+ data.key?("data") &&
53
+ data["data"].is_a?(Hash) &&
54
+ data["data"].key?("values") &&
55
+ data["data"].key?("series")
56
+ end
57
+
58
+ def build_from_segmentation(data)
59
+ series = data["data"]["series"]
60
+ values = data["data"]["values"]
61
+ headers = ["segment"] + series
62
+ rows = values.map do |segment, date_values|
63
+ [segment] + series.map { |date| date_values[date] }
64
+ end
65
+ [headers, rows]
66
+ end
67
+
68
+ def engage_data?(data)
69
+ data.key?("results") && data["results"].is_a?(Array)
70
+ end
71
+
72
+ def build_from_engage(data)
73
+ results = data["results"]
74
+ return [nil, []] if results.empty?
75
+
76
+ rows = results.map { |r| flatten_hash(r) }
77
+ headers = rows.flat_map(&:keys).uniq
78
+ table_rows = rows.map { |r| headers.map { |h| r[h] } }
79
+ [headers, table_rows]
80
+ end
81
+
82
+ def funnel_data?(data)
83
+ data.key?("data") &&
84
+ data["data"].is_a?(Hash) &&
85
+ data["data"].values.any? { |v| v.is_a?(Hash) && v.key?("steps") }
86
+ end
87
+
88
+ def build_from_funnel(data)
89
+ headers = %w[date step_idx event count step_conv_ratio overall_conv_ratio]
90
+ rows = []
91
+ data["data"].each do |date, date_data|
92
+ next unless date_data.is_a?(Hash) && date_data["steps"].is_a?(Array)
93
+
94
+ date_data["steps"].each_with_index do |step, idx|
95
+ rows << [
96
+ date,
97
+ idx + 1,
98
+ step["event"],
99
+ step["count"],
100
+ step["step_conv_ratio"],
101
+ step["overall_conv_ratio"]
102
+ ]
103
+ end
104
+ end
105
+ [headers, rows]
106
+ end
107
+
108
+ def retention_data?(data)
109
+ data.key?("data") &&
110
+ data["data"].is_a?(Hash) &&
111
+ !data["data"].key?("series")
112
+ end
113
+
114
+ def build_from_retention(data)
115
+ build_from_flat_hash(data)
116
+ end
117
+
118
+ def build_from_flat_hash(data)
119
+ flat = flatten_hash(data)
120
+ headers = %w[key value]
121
+ rows = flat.map { |k, v| [k, v] }
122
+ [headers, rows]
123
+ end
124
+
125
+ def flatten_hash(hash, prefix = nil)
126
+ hash.each_with_object({}) do |(key, value), result|
127
+ full_key = prefix ? "#{prefix}.#{key}" : key.to_s
128
+ case value
129
+ when Hash
130
+ result.merge!(flatten_hash(value, full_key))
131
+ when Array
132
+ if value.all?(Hash)
133
+ value.each_with_index do |item, i|
134
+ result.merge!(flatten_hash(item, "#{full_key}.#{i}"))
135
+ end
136
+ else
137
+ result[full_key] = value
138
+ end
139
+ else
140
+ result[full_key] = value
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "terminal-table"
5
+ require_relative "row_builder"
6
+
7
+ module Mpql
8
+ module Formatters
9
+ class TableFormatter
10
+ def initialize(data)
11
+ @builder = RowBuilder.new(data)
12
+ end
13
+
14
+ def render
15
+ table = Terminal::Table.new(
16
+ headings: @builder.headers,
17
+ rows: @builder.rows.map { |row| row.map { |v| format_value(v) } }
18
+ )
19
+ table.to_s
20
+ end
21
+
22
+ private
23
+
24
+ def format_value(value)
25
+ case value
26
+ when nil
27
+ ""
28
+ when Hash, Array
29
+ JSON.generate(value)
30
+ else
31
+ value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "row_builder"
5
+
6
+ module Mpql
7
+ module Formatters
8
+ class TsvFormatter
9
+ def initialize(data)
10
+ @builder = RowBuilder.new(data)
11
+ end
12
+
13
+ def render
14
+ lines = []
15
+ lines << @builder.headers.join("\t") if @builder.headers
16
+ @builder.rows.each { |row| lines << row.map { |v| format_value(v) }.join("\t") }
17
+ lines.join("\n")
18
+ end
19
+
20
+ private
21
+
22
+ def format_value(value)
23
+ case value
24
+ when nil
25
+ ""
26
+ when String
27
+ value.gsub("\t", "\\t").gsub("\n", "\\n")
28
+ when Hash, Array
29
+ JSON.generate(value).gsub("\t", "\\t").gsub("\n", "\\n")
30
+ else
31
+ value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mpql
4
+ VERSION = "0.0.1"
5
+ end
data/lib/mpql.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mpql/version"
4
+
5
+ module Mpql
6
+ class Error < StandardError; end
7
+ class AuthenticationError < Error; end
8
+ class ApiError < Error; end
9
+ class ConfigurationError < Error; end
10
+ class InputError < Error; end
11
+
12
+ autoload :Configuration, "mpql/configuration"
13
+ autoload :Client, "mpql/client"
14
+ autoload :DateParser, "mpql/date_parser"
15
+
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ end
24
+
25
+ def reset_configuration!
26
+ @configuration = nil
27
+ end
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mpql
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - tomorrowkey
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: terminal-table
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ description: A command-line tool to execute MixPanel Query API requests. Designed
41
+ for both human users and AI agents.
42
+ executables:
43
+ - mpql
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE.txt
48
+ - README.md
49
+ - exe/mpql
50
+ - lib/mpql.rb
51
+ - lib/mpql/cli.rb
52
+ - lib/mpql/client.rb
53
+ - lib/mpql/configuration.rb
54
+ - lib/mpql/date_parser.rb
55
+ - lib/mpql/formatters/json_formatter.rb
56
+ - lib/mpql/formatters/row_builder.rb
57
+ - lib/mpql/formatters/table_formatter.rb
58
+ - lib/mpql/formatters/tsv_formatter.rb
59
+ - lib/mpql/version.rb
60
+ homepage: https://github.com/tomorrowkey/mpql
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ rubygems_mfa_required: 'true'
65
+ source_code_uri: https://github.com/tomorrowkey/mpql
66
+ changelog_uri: https://github.com/tomorrowkey/mpql/blob/main/CHANGELOG.md
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 3.0.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.3
82
+ specification_version: 4
83
+ summary: CLI tool for MixPanel Query API
84
+ test_files: []