risa 1.0.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.
data/README.md ADDED
@@ -0,0 +1,702 @@
1
+ # Risa (Ruby Is So Awesome)
2
+
3
+ ## Advanced Querying & Presentation Layer for Ruby Hashes
4
+
5
+ You know that feeling when you're prototyping and you just need to store some structured data without the ceremony of setting up a database, migrations, and ORMs?
6
+
7
+ Or when you're building a static site and you're tired of wrestling with YAML parsing errors and JSON schema headaches?
8
+
9
+ Meet Risa. It's the Ruby library you didn't know you were missing.
10
+
11
+ ## The "Aha!" Moment
12
+
13
+ ```ruby
14
+ # Instead of this YAML nightmare:
15
+ # data/posts.yml
16
+ # ---
17
+ # - id: 1
18
+ # title: "My Post" # YAML gotcha: this needs quotes
19
+ # published_at: 2024-01-01 # Is this a string? Date? Who knows!
20
+
21
+ # You write pure Ruby (like you wanted all along):
22
+ Risa.define :posts do
23
+ from_array([
24
+ { id: 1, title: 'Hello World', published: true, created_at: Date.new(2024, 1, 1) },
25
+ { id: 2, title: 'Ruby Magic', published: false, created_at: Date.new(2024, 1, 15) }
26
+ ])
27
+ end
28
+
29
+ # Query it like you always wished you could:
30
+ rs(:posts).where(published: true).order(:created_at, desc: true).first[:title]
31
+ # => "Hello World"
32
+ ```
33
+
34
+ No schema. No migrations. No database setup. Just Ruby being Ruby.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```ruby
41
+ # Gemfile
42
+ gem 'risa'
43
+
44
+ # Or if you're feeling spontaneous:
45
+ gem install joys
46
+ ```
47
+
48
+ ## Quick Start: From Zero to Querying in 30 Seconds
49
+
50
+ ```ruby
51
+ require 'risa'
52
+
53
+ # Define your data (arrays, files, whatever)
54
+ Risa.define :posts do
55
+ from_array([
56
+ { id: 1, title: 'Why I Love Ruby', tags: ['ruby', 'opinion'], views: 1337 },
57
+ { id: 2, title: 'JavaScript Fatigue', tags: ['js', 'rant'], views: 9001 },
58
+ { id: 3, title: 'The Perfect Deploy', tags: ['ops', 'ruby'], views: 500 }
59
+ ])
60
+ end
61
+
62
+ # Query like you always wanted to:
63
+ popular_ruby_posts = rs(:posts)
64
+ .where(tags: { contains: 'ruby' })
65
+ .where(views: { greater_than: 1000 })
66
+ .order(:views, desc: true)
67
+ .all
68
+
69
+ puts popular_ruby_posts.first[:title] # => "Why I Love Ruby"
70
+ ```
71
+
72
+ That global `rs()` helper? It's there because life's too short to type `Risa.query()` every time.
73
+
74
+ ---
75
+
76
+ ## Data Sources: Choose Your Own Adventure
77
+
78
+ ### Option 1: Inline Arrays (Perfect for Prototyping)
79
+
80
+ When you just need to get something working:
81
+
82
+ ```ruby
83
+ Risa.define :users do
84
+ from_array([
85
+ { id: 1, name: 'Alice', role: 'admin', coffee_preference: 'black' },
86
+ { id: 2, name: 'Bob', role: 'user', coffee_preference: 'latte' },
87
+ { id: 3, name: 'Carol', role: 'editor', coffee_preference: 'espresso' }
88
+ ])
89
+ end
90
+ ```
91
+
92
+ ### Option 2: File-Based Data (The Grown-Up Way)
93
+
94
+ Your data files are just Ruby scripts that return hashes. No parsing, no gotchas:
95
+
96
+ ```ruby
97
+ # data/posts/001-hello-world.rb
98
+ {
99
+ id: 1,
100
+ title: 'Hello World',
101
+ slug: 'hello-world',
102
+ published_at: Date.new(2024, 1, 1),
103
+ tags: ['ruby', 'beginnings'],
104
+ meta: {
105
+ author: 'You',
106
+ reading_time: '2 min'
107
+ }
108
+ }
109
+
110
+ # data/posts/002-advanced-stuff.rb
111
+ {
112
+ id: 2,
113
+ title: 'Advanced Ruby Wizardry',
114
+ slug: 'advanced-ruby-wizardry',
115
+ published_at: Date.new(2024, 1, 15),
116
+ tags: ['ruby', 'advanced'],
117
+ meta: {
118
+ author: 'You (but smarter)',
119
+ reading_time: '15 min'
120
+ }
121
+ }
122
+ ```
123
+
124
+ ```ruby
125
+ Risa.define :posts do
126
+ load 'posts/*.rb' # Loads in sorted filename order
127
+ end
128
+
129
+ # Want a different data directory? No problem:
130
+ Risa.configure(data_path: 'content/data')
131
+ ```
132
+
133
+ Pro tip: Your editor will syntax highlight these files, catch typos, and even provide autocomplete. Try doing that with YAML.
134
+
135
+ ---
136
+
137
+ ## Scopes: Make Your Queries Reusable
138
+
139
+ Remember writing the same `.where()` chains over and over? Those days are over:
140
+
141
+ ```ruby
142
+ Risa.define :posts do
143
+ from_array([...]) # Your data
144
+
145
+ scope({
146
+ published: -> { where(published: true) },
147
+ featured: -> { where(featured: true) },
148
+ recent: ->(n=10) { order(:published_at, desc: true).limit(n) },
149
+ tagged: ->(tag) { where(tags: { contains: tag }) },
150
+ popular: -> { where(views: { greater_than: 100 }).order(:views, desc: true) }
151
+ })
152
+ end
153
+
154
+ # Now your queries read like English:
155
+ trending_ruby_posts = rs(:posts).published.tagged('ruby').popular.recent(5)
156
+ featured_content = rs(:posts).published.featured.recent
157
+ ```
158
+
159
+ Scopes are chainable, composable, and parameterizable. They're basically custom query methods that don't suck.
160
+
161
+ ---
162
+
163
+ ## Querying: The Good Stuff
164
+
165
+ Risa provides a fluent, chainable API for filtering and sorting your data. Queries are lazy—they only execute when you ask for the results (e.g., by calling `.first`, `.map`, `.to_a`, `.each`, `.size`).
166
+
167
+ ### Basic Queries (The Classics)
168
+
169
+ ```ruby
170
+ posts = rs(:posts) # Get the query builder for :posts
171
+
172
+ # Get results
173
+ posts.to_a # Get all matching records as an array of Risa::InstanceWrapper objects
174
+ posts.first # First match or nil
175
+ posts.last # Last match (respects order) or nil
176
+ posts.count # Fast count of matching records
177
+
178
+ # Find by specific attribute(s)
179
+ posts.find_by(slug: 'hello-world') # Convenience for where(slug: '...').first
180
+ posts.find_by(id: 1)
181
+ ```
182
+
183
+ **Note:** You no longer need to call `.all`. The query object itself acts like an array when you iterate (`.each`, `.map`) or access its size (`.size`, `.count`, `.length`). Use `.to_a` if you specifically need a plain `Array`.
184
+
185
+ ### Where Clauses (AND Logic)
186
+
187
+ Chain multiple `.where` calls or provide multiple conditions in a hash to combine filters with `AND`.
188
+
189
+ ```ruby
190
+ # Find published posts tagged 'ruby'
191
+ rs(:posts).where(published: true).where(tags: { contains: 'ruby' })
192
+
193
+ # Equivalent using a single hash
194
+ rs(:posts).where(published: true, tags: { contains: 'ruby' })
195
+ ```
196
+
197
+ ### OR Logic (`or_where`)
198
+
199
+ Use `.or_where` to add conditions combined with `OR`.
200
+
201
+ ```ruby
202
+ # Find posts that are featured OR have more than 1000 views
203
+ rs(:posts).where(featured: true).or_where(views: { greater_than: 1000 })
204
+ # => WHERE featured = true OR views > 1000
205
+ ```
206
+
207
+ ### Grouping Conditions with Blocks
208
+
209
+ Use blocks with `where` and `or_where` to create nested logical groups. Conditions inside a block are implicitly joined by `AND` unless `or_where` is used within that block.
210
+
211
+ ```ruby
212
+ # Find posts where (author_id = 1 AND published = true)
213
+ rs(:posts).where do |q|
214
+ q.where(author_id: 1).where(published: true)
215
+ end
216
+
217
+ # Find posts where (author_id = 1 AND (published = true OR featured = true))
218
+ rs(:posts).where(author_id: 1).where do |q|
219
+ q.where(published: true).or_where(featured: true)
220
+ end
221
+ # => WHERE author_id = 1 AND (published = true OR featured = true)
222
+
223
+ # Find posts where (author_id = 1 AND published = true) OR (views > 1000)
224
+ rs(:posts).where { |q| q.where(author_id: 1).where(published: true) }
225
+ .or_where(views: { greater_than: 1000 })
226
+ # => WHERE (author_id = 1 AND published = true) OR views > 1000
227
+
228
+ # Find posts where (author_id = 1 AND published = true) OR (author_id = 2 AND featured = true)
229
+ rs(:posts).where { |q| q.where(author_id: 1).where(published: true) }
230
+ .or_where { |q| q.where(author_id: 2).where(featured: true) }
231
+ # => WHERE (author_id = 1 AND published = true) OR (author_id = 2 AND featured = true)
232
+ ```
233
+
234
+ ### Operators & Negation (Hash Conditions)
235
+
236
+ Use hash conditions within `where` or `or_where` for powerful comparisons and negation.
237
+
238
+ ```ruby
239
+ # Text searches
240
+ rs(:posts).where(title: { contains: 'Ruby' })
241
+ rs(:posts).where(title: { starts_with: 'How to' })
242
+ rs(:posts).where(title: { ends_with: '101' })
243
+
244
+ # Numeric comparisons
245
+ rs(:posts).where(views: { greater_than: 1000 })
246
+ rs(:posts).where(views: { less_than_or_equal: 500 })
247
+ rs(:posts).where(score: { from: 7.5, to: 9.0 }) # Inclusive range
248
+ rs(:posts).where(views: 100..500) # Ruby Range works too
249
+
250
+ # Existence and emptiness
251
+ rs(:posts).where(featured_image: { exists: true }) # Key is present and not nil
252
+ rs(:posts).where(featured_image: { exists: false }) # Key is missing or nil
253
+ rs(:posts).where(tags: { empty: false }) # Not nil, not '', not []
254
+ rs(:posts).where(tags: { empty: true }) # Is nil, '', or []
255
+
256
+ # Array / Set operations
257
+ rs(:posts).where(id: { in: [1, 3, 5] }) # Value is one of these
258
+ rs(:posts).where(id: [1, 3, 5]) # Shortcut for :in
259
+ rs(:posts).where(status: { not_in: ['draft', 'archived'] }) # Value is NOT one of these
260
+ rs(:posts).where(tags: ['ruby', 'web']) # Exact array match (order matters)
261
+
262
+ # Negation
263
+ rs(:posts).where(published: { not: true }) # Value is not true (false or nil)
264
+ rs(:posts).where(title: { not: 'Hello' }) # Value is not 'Hello'
265
+ rs(:posts).where(views: { not: nil }) # Same as { exists: true }
266
+ ```
267
+
268
+ ### Ordering (Nil-Safe and Type-Aware)
269
+
270
+ Sort your results using `.order`. Nils are always sorted last.
271
+
272
+ ```ruby
273
+ # Ascending (default)
274
+ rs(:posts).order(:published_at)
275
+
276
+ # Descending
277
+ rs(:posts).order(:views, desc: true)
278
+
279
+ # Strings sort naturally
280
+ rs(:posts).order(:title)
281
+ ```
282
+
283
+ Mixed types? No problem. Risa handles the type coercion so you don't have to think about it during sorting.
284
+
285
+ -----
286
+ ### Limiting and Pagination
287
+
288
+ ```ruby
289
+ # Classic pagination
290
+ rs(:posts).limit(10) # First 10
291
+ rs(:posts).offset(20).limit(10) # Items 21-30
292
+
293
+ # Modern pagination with metadata
294
+ pages = rs(:posts).order(:created_at, desc: true).paginate(per_page: 5)
295
+
296
+ page = pages.first
297
+ page.items # Array of posts for this page
298
+ page.current_page # 1
299
+ page.total_pages # 4
300
+ page.total_items # 18
301
+ page.next_page # 2 (or nil if last page)
302
+ page.prev_page # nil (or previous page number)
303
+ page.is_first_page # true
304
+ page.is_last_page # false
305
+
306
+ # Perfect for building pagination UI
307
+ pages.each do |page|
308
+ puts "Page #{page.current_page}: #{page.items.size} posts"
309
+ end
310
+ ```
311
+
312
+ The Page object has everything you need for pagination UI without any mental math.
313
+
314
+ ---
315
+
316
+ ## Instance Wrappers: Hash-Like, But Better
317
+
318
+ Results aren't plain hashes—they're immutable wrappers that feel like hashes but prevent accidents:
319
+
320
+ ```ruby
321
+ post = rs(:posts).first
322
+
323
+ # Access like a hash (symbol or string keys both work)
324
+ post[:title] # => "Hello World"
325
+ post['title'] # => "Hello World" (same thing)
326
+
327
+ # All the hash methods you expect
328
+ post.keys # => [:id, :title, :content, ...]
329
+ post.size # => 5
330
+ post.has_key?(:id) # => true
331
+ post.include?('title') # => true
332
+ post.to_h # Raw hash if you need it
333
+
334
+ # But immutable (this raises an error)
335
+ post[:title] = 'New Title' # RuntimeError: collections are immutable
336
+
337
+ # Custom methods work too
338
+ post.excerpt(20) # Your custom methods
339
+ post.reading_time # Computed properties
340
+ ```
341
+
342
+ It's like getting a hash that went to finishing school.
343
+
344
+ ---
345
+
346
+ ## Development Helpers (For Your Sanity)
347
+
348
+ ```ruby
349
+ # Reload everything during development
350
+ Risa.reload
351
+
352
+ # See what models you've defined
353
+ Risa.defined_models # => [:posts, :users, :tags]
354
+
355
+ # Use the explicit API when you need it
356
+ Risa.query(:posts).where(...) # Same as rs(:posts).where(...)
357
+ ```
358
+
359
+ Error messages are actually helpful:
360
+ - Syntax errors in data files show the exact file and line
361
+ - Missing files tell you the exact pattern that failed
362
+ - Type errors explain what was expected vs. what was found
363
+
364
+ ---
365
+
366
+ ## Relationships: Connecting Your Data
367
+
368
+ Risa makes it easy to define relationships between your data collections, similar to ActiveRecord associations. Define them right inside your `Risa.define` block.
369
+
370
+ ### Defining Relationships
371
+
372
+ Use `belongs_to`, `has_many`, and `has_one` to link your data. Risa uses conventions for keys but allows overrides.
373
+
374
+ ```ruby
375
+ # --- Authors ---
376
+ Risa.define :authors do
377
+ from_array([
378
+ { id: 1, name: 'Alice' },
379
+ { id: 2, name: 'Bob' }
380
+ ])
381
+
382
+ # Author has many posts (looks for :author_id in :posts)
383
+ has_many :posts
384
+
385
+ # Author has one profile (looks for :author_id in :profiles)
386
+ has_one :profile
387
+ end
388
+
389
+ # --- Posts ---
390
+ Risa.define :posts do
391
+ from_array([
392
+ { id: 101, title: 'Intro to Risa', author_id: 1 },
393
+ { id: 102, title: 'Advanced Ruby', author_id: 1 },
394
+ { id: 103, title: 'Web Development', author_id: 2 }
395
+ ])
396
+
397
+ # Post belongs to an author (looks for :author_id here, links to :authors using :id)
398
+ belongs_to :author
399
+ end
400
+
401
+ # --- Profiles ---
402
+ Risa.define :profiles do
403
+ from_array([
404
+ { profile_id: 201, bio: 'Ruby Developer', author_id: 1 }, # Note: primary key is :profile_id
405
+ { profile_id: 202, bio: 'Web Enthusiast', author_id: 2 }
406
+ ])
407
+
408
+ # Profile belongs to an author (looks for :author_id here)
409
+ # Target model (:authors) uses :id as primary key by default
410
+ belongs_to :author
411
+ end
412
+ ```
413
+
414
+ ### Accessing Relationships
415
+
416
+ Access related data using simple dot notation on your `Risa::InstanceWrapper` objects.
417
+
418
+ ```ruby
419
+ alice = rs(:authors).find_by(id: 1)
420
+ post = rs(:posts).find_by(id: 101)
421
+ profile = rs(:profiles).find_by(profile_id: 201)
422
+
423
+ # Belongs To (returns InstanceWrapper or nil)
424
+ author_name = post.author.name
425
+ # => "Alice"
426
+ author_bio = profile.author.profile.bio # Chain through relations
427
+ # => "Ruby Developer"
428
+
429
+ # Has One (returns InstanceWrapper or nil)
430
+ alices_bio = alice.profile.bio
431
+ # => "Ruby Developer"
432
+
433
+ # Has Many (returns a Risa::Query object)
434
+ alices_posts = alice.posts
435
+ # => <Risa::Query @model_name=:posts ...>
436
+
437
+ # You can chain queries on has_many results
438
+ published_titles = alice.posts.where(published: true).map { |p| p.title }
439
+ # => ["Intro to Risa"]
440
+ ```
441
+
442
+ ### Overriding Conventions
443
+
444
+ Need custom keys or class names? No problem.
445
+
446
+ ```ruby
447
+ Risa.define :users do
448
+ from_array([{ user_pk: 501, username: 'admin' }]) # Custom primary key
449
+
450
+ # Specify foreign_key in posts (:creator_id) and owner_key here (:user_pk)
451
+ has_many :articles, class_name: :posts, foreign_key: :creator_id, owner_key: :user_pk
452
+ end
453
+
454
+ Risa.define :posts do
455
+ # ... other posts ...
456
+ from_array([{ id: 105, title: 'Admin Post', creator_id: 501 }]) # Custom foreign key
457
+
458
+ # Specify foreign_key here (:creator_id) and primary_key on users (:user_pk)
459
+ belongs_to :creator, class_name: :users, foreign_key: :creator_id, primary_key: :user_pk
460
+ end
461
+
462
+ admin = rs(:users).first
463
+ admin_article_title = admin.articles.first.title
464
+ # => "Admin Post"
465
+
466
+ post = rs(:posts).find_by(id: 105)
467
+ creator_name = post.creator.username
468
+ # => "admin"
469
+ ```
470
+
471
+ ### Many-to-Many (`has_many :through`)
472
+
473
+ Define the intermediate `has_many` first, then the `through` relationship.
474
+
475
+ ```ruby
476
+ Risa.define :posts do
477
+ # ... (fields, belongs_to :author) ...
478
+ has_many :post_tags # Link to the join collection
479
+ has_many :tags, through: :post_tags # Go through :post_tags to find :tags
480
+ # (infers :tag on PostTag model)
481
+ end
482
+
483
+ Risa.define :tags do
484
+ from_array([ { id: 301, name: 'ruby' }, { id: 302, name: 'web' } ])
485
+ has_many :post_tags
486
+ has_many :posts, through: :post_tags # Go through :post_tags to find :posts
487
+ # (infers :post on PostTag model)
488
+ end
489
+
490
+ Risa.define :post_tags do # The join collection
491
+ from_array([
492
+ { id: 401, post_id: 101, tag_id: 301 },
493
+ { id: 402, post_id: 101, tag_id: 302 },
494
+ { id: 403, post_id: 102, tag_id: 301 }
495
+ ])
496
+ belongs_to :post # Link back to posts
497
+ belongs_to :tag # Link back to tags
498
+ end
499
+
500
+ # Usage:
501
+ post = rs(:posts).find_by(id: 101)
502
+ tag_names = post.tags.map { |t| t.name }
503
+ # => ["ruby", "web"]
504
+
505
+ tag = rs(:tags).find_by(name: 'ruby')
506
+ post_titles = tag.posts.map { |p| p.title }
507
+ # => ["Intro to Risa", "Advanced Ruby"]
508
+
509
+ # Override source if needed:
510
+ # has_many :categories, through: :post_categories, source: :category
511
+ ```
512
+
513
+ Relationships in Risa provide a powerful yet simple way to navigate your connected data directly within Ruby.
514
+
515
+ ---
516
+
517
+ ## Presenters: Adding Behavior to Your Data
518
+
519
+ While `Risa` focuses on querying, you often need helper methods for formatting or deriving information from your data records. Instead of cluttering your view logic, you can define these directly using `Risa.present`. This replaces the older, less ergonomic `item` block.
520
+
521
+ ### Defining Presenter Methods
522
+
523
+ Use standard Ruby `def` syntax within a `Risa.present` block associated with your model name. Inside these methods, `self` refers to the `Risa::InstanceWrapper` object, allowing you to access the underlying data using `self[:key]` or just `key` (if the key doesn't clash with a method name).
524
+
525
+ ```ruby
526
+ Risa.define :posts do
527
+ from_array([
528
+ { id: 1, title: 'Hello World', slug: 'hello-world', content: 'This is the first post.', published_at: Date.today }
529
+ ])
530
+ belongs_to :author # Example relation
531
+ end
532
+
533
+ # Define presenter methods for the :posts model
534
+ Risa.present :posts do
535
+ # Simple formatting
536
+ def formatted_date
537
+ self[:published_at]&.strftime('%B %d, %Y') # Access data with self[:key]
538
+ end
539
+
540
+ # Derived data
541
+ def url
542
+ "/posts/#{slug}/" # Access data directly via dynamically defined method 'slug'
543
+ end
544
+
545
+ # Methods with arguments
546
+ def excerpt(word_count = 25)
547
+ words = content.split # Access data via 'content' method
548
+ return content if words.size <= word_count
549
+ words.take(word_count).join(' ') + '...'
550
+ end
551
+
552
+ # Accessing relations within presenters
553
+ def author_name
554
+ author&.name || 'Anonymous' # Call the 'author' relation method
555
+ end
556
+ end
557
+ ```
558
+
559
+ ### Using Presenter Methods
560
+
561
+ Presenter methods are automatically available directly on the `InstanceWrapper` objects returned by your queries.
562
+
563
+ ```ruby
564
+ post = rs(:posts).first
565
+
566
+ # Access hash data keys
567
+ puts post.id # => 1
568
+ puts post[:title] # => "Hello World" (Hash access still works)
569
+
570
+ # Call presenter methods
571
+ puts post.url # => "/posts/hello-world/"
572
+ puts post.formatted_date # => "October 27, 2025"
573
+ puts post.excerpt(3) # => "This is the..."
574
+ puts post.author_name # => (Assuming author relation works) "Alice" or "Anonymous"
575
+ ```
576
+
577
+ ### Benefits
578
+
579
+ * **Clean Ruby Syntax:** Uses standard `def`
580
+ * **Clear Separation:** Keeps presentation logic separate from the core data definition (`Risa.define`).
581
+ * **Precedence:** Presenter methods will override accessor methods created for hash keys if they share the same name. Access the original hash value using `self[:key]` if needed.
582
+
583
+ Use `Risa.present` to keep your data definitions clean and add reusable display logic directly to your data objects.
584
+
585
+ ---
586
+
587
+ ## Auto-Loading Data Files
588
+
589
+ Instead of manually requiring data files, use `Risa.load_from` to automatically discover and load all data definitions:
590
+
591
+ ```ruby
592
+ # Directory structure:
593
+ # data/
594
+ # users.rb
595
+ # posts.rb
596
+ # categories.rb
597
+
598
+ # config.ru or boot file
599
+ require 'hr'
600
+
601
+ Risa.load_from('data') # Loads all .rb files in data/
602
+
603
+ # Now all collections are available
604
+ rs(:users).where(active: true)
605
+ rs(:posts).order(:created_at)
606
+ ```
607
+
608
+ **Development mode reloading:**
609
+
610
+ ```ruby
611
+ if ENV['RACK_ENV'] == 'development'
612
+ require 'listen'
613
+
614
+ listener = Listen.to('data') do |modified, added, removed|
615
+ puts "Data changed, reloading..."
616
+ Risa.reload_from('data')
617
+ end
618
+ listener.start
619
+ end
620
+ ```
621
+
622
+ Files are loaded in alphabetical order. Use nested directories to organize large data sets:
623
+
624
+ ```ruby
625
+ data/
626
+ blog/
627
+ posts.rb
628
+ categories.rb
629
+ users/
630
+ admins.rb
631
+ customers.rb
632
+
633
+ Risa.load_from('data') # Loads everything recursively
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Performance Notes (For the Curious)
639
+
640
+ **The Good News:** Risa is fast enough for most use cases. We're talking thousands of items with complex queries in milliseconds.
641
+
642
+ **The Technical Details:**
643
+ - Everything lives in memory (no I/O after initial load)
644
+ - Queries are lazy—filters only apply when you call `.all`, `.first`, etc.
645
+ - Data is frozen at load time (immutable and thread-safe)
646
+ - Method results are memoized automatically
647
+ - Under 500 lines of core code (minimal overhead)
648
+
649
+ **Sweet Spot:** Hundreds to low thousands of items. Perfect for:
650
+ - Blog posts and pages
651
+ - Product catalogs
652
+ - Team directories
653
+ - Configuration data
654
+ - Documentation sites
655
+ - Prototype datasets
656
+
657
+ **When to Graduate to a Real Database:**
658
+ - Tens of thousands of items
659
+ - Real-time updates needed
660
+ - Complex relationships and joins
661
+ - Multi-user concurrent writes
662
+
663
+ ---
664
+
665
+ ## When Risa Shines
666
+
667
+ **Perfect For:**
668
+ - Static site content management
669
+ - Rapid prototyping with structured data
670
+ - Configuration and settings management
671
+ - Small-to-medium datasets that don't change often
672
+ - Replacing hand-rolled JSON/YAML parsers
673
+ - When you want SQL-like querying without SQL complexity
674
+
675
+ **Not Ideal For:**
676
+ - Large-scale data (stick with SQLite/PostgreSQL/MySQL)
677
+ - Real-time analytics
678
+ - Data that changes frequently
679
+ - Multi-table joins and complex relationships
680
+ - When you actually need ACID transactions
681
+
682
+ ---
683
+
684
+ ## The Philosophy
685
+
686
+ We built Risa because we were tired of the false choice between "simple but limited" and "powerful but complex."
687
+
688
+ Why should prototyping with structured data require setting up a database? Why should static sites need a complex build pipeline just to query some content? Why can't data files be as expressive as the rest of our Ruby code?
689
+
690
+ Risa is our answer: a data layer that grows with your project. Start with arrays, move to files, add scopes and methods as needed. When you outgrow it, you'll have learned exactly what you need from a real database.
691
+
692
+ It's the tool we always wished existed. Now it does.
693
+
694
+ ---
695
+
696
+ **Ready to feel the joy of simple, powerful data?**
697
+
698
+ ```bash
699
+ gem install hr
700
+ ```
701
+
702
+ Your future self will thank you.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test