'whose'/'where' imitator for AppleScript lists

‘whose’ (aka ‘where’) filters can’t, unfortunately, be applied to AppleScript lists. While it’s easy enough to write your own code to search a list for items matching specific criteria, the handler below is a generic effort which takes the criteria as parameters. Like a real ‘whose’ filter, it needs to be told the host object (here the list), the class of item to return, which match instance(s) to return, and the predicate the items have to match.

Say you have a list of text and you want to perform the equivalent of item 1 of myList where it begins with “s”. At its simplest, you can call the handler with:

my filterList:myList forInstance:1 ofClass:(item) |where|:"it begins with \"s\""

If the list contains items of more than one class, or simply if you prefer, you can be more specific about the type of object required:

my filterList:myList forInstance:1 ofClass:(text) |where|:"it begins with \"s\""

The ofClass: parameter can be any relevant AppleScript class keyword, including number. With the help of using terms from …, it’s possible to specify application class keywords as well, but I haven’t looked into this very deeply. With “interleaved parameter” handlers like this one, it’s necessary to parenthesise keywords when they’re written directly into a call. Such handlers also require the script containing them to be identified in the calls, even when the calls and the handler are in the same script. That’s why all the examples here begin with ‘my’.

The forInstance: parameter indicates which of any matching items to return. An integer value is like the integer in an index reference and can be positive or negative, but not zero. Index references also allow anything which can be coerced to integer (or have done up to now), and it’s the same with this handler.

A range of matches can be specified with a two-integer list …

-- text 1 thru -4 of myList where it begins with "s".
my filterList:myList forInstance:{1, -4} ofClass:(text) |where|:"it begins with \"s\""

… and an ‘every’ match with an empty list:

-- every text of myList where it begins with "s".
my filterList:myList forInstance:{} ofClass:(text) |where|:"it begins with \"s\""

The |where|: predicate parameter can be text, as above, or a list or a script object, whichever’s convenient at the time. The list form can contain text or text fragments similar to the above and/or two-item lists containing text and an actual value. These two-item lists are templates for when the value you want to use is in a variable or is otherwise fiddly to represent as text.

set alternativeStartLetter to "s"
my filterList:myList forInstance:{} ofClass:(text) |where|:{"it begins with \"g\"", "or", {"it begins with", alternativeStartLetter}}
--
my filterList:myList forInstance:{} ofClass:(list) |where|:{{"its length ≥", (count list2)}, "and", {"its fourth item is", {a:"aardvark", b:"Hubert \"Knuckles\" O'Shaunessy", c:"{a:\"aardvark\", b:\"Hubert \\\"Knuckles\\\" O'Shaunessy\"}"}}}

In a compound predicate, it’s recommended that template lists cover just one condition each. It’s possible for them to cover more:

my filterList:myList forInstance:{} ofClass:(text) |where|:{"its length is 7 and", {"it begins with \"g\" or it begins with", "s"}}

… but the conditions are then parenthesised togther — as in its length is 7 and (it begins with “g” or it begins with “s”) — so only do this if it’s what you actually mean.

If preferred, you can write a script object to perform the tests and use that as the |where|: parameter. It must contain a one-parameter handler labelled isMatch() which returns a boolean indicating whether or not the parameter matches the ‘where’ requirements:

script myScriptObject
	property alternativeStartLetter : missing value

	on isMatch(a) -- a is an item from the list being filtered.
		return ((a's length is 7) and ((a begins with "g") or (a begins with alternativeStartLetter)))
	end isMatch
end script

set myScriptObject's alternativeStartLetter to "s"
my filterList:myList forInstance:{} ofClass:(text) |where|:myScriptObject

Like all ‘whose’ filters I’ve tried, the handler errors if there aren’t enough matches to cover all the requested instances. But if you’ve passed an empty list to ask for ‘every’ match, and it turns out there are none, you get back an empty list.

I think that covers everything. Here’s the handler:

(* 'whose'/'where' imitator for AppleScript lists, by Nigel Garvey 2018.

Handler format:  filterList:forInstance:ofClass:|where|:

Parameters:
	filterList:
		The list from which to derive the filtered result.
		
	forInstance:
		Either an integer denoting the instance number of the match to return;
		or a list of two integers indicating a range of matching instances to return;
		or an empty list, signifying that every matching item should be returned.
		Values which are coercible to integer are treated as integers.
		
	ofClass:
		The keyword for the class of item to match. It will need to be parenthesised if coded directly into a call.
		
	|where|:
		Either a script object containing an isMatch(a) handler which returns true or false according to whether or not an item passed to it matches the conditions, eg.:
			script
				on isMatch(a) -- 'a' is an item from the list.
					return (a's length < 32)
				end isMatch
			end script
		or text representing the source code for a predicate in the form it might appear after 'where' in a real filter. eg.:
			"its length < 32"
			"(its fourth item is 5) and ((its item 2 begins with \"z\") or (its item 7 is {|name|:\"Fred\", age:109}))"
		or a list containing text(s) and/or {text, value} lists (which represent templates for individual conditions) from which such a predicate can be constucted.
			{"its length < 32"}
			{{"its length <", 32}}
			{"(its fourth item is 5)", "and", "((its item 2 begins with \"z\")", "or", "(its item 7 is {|name|:\"Fred\", age:109}))"}
			{{"its fourth item is", 5}, "and (", {"its item 2 begins with", "z"}, "or", {"its item 7 is", {|name|:"Fred", age:109}}, ")"}
			
Result:
	The requested match(es) if fully achievable. Otherwise an error.
*)

on filterList:theList forInstance:n ofClass:requiredClass |where|:thePredicate
	script o
		property sourceList : theList
		property matchedItems : {} -- For multiple matches, if required.
	end script
	
	-- Check that the list parameter is in fact a list.
	if (theList's class is not list) then error "filterList:forInstance:ofClass:|where|: : the filterList: parameter isn't a list." number -1700 from theList to list
	
	-- Analyse the instance parameter. Integer (single match index), two-integer list (indices for a range of matches), or empty list (every match)?
	try
		set singleMatchWanted to ((n's class is not list) or ((count n) is 1))
		if (singleMatchWanted) then
			-- If it's not a list, or is a single-item one, try coercing it to a non-zero integer.
			set n to n as integer
			if (n is 0) then error
			-- If it's negative, reverse the source list and use a positive index for convenience and speed.
			if (n < 0) then
				set o's sourceList to reverse of o's sourceList
				set n to -n
			end if
		else if (n is not {}) then
			-- If it's a non-empty list, check that it only contains two items and derive non-zero integers from both of them.
			if ((count n) > 2) then error
			set {n1, n2} to {beginning of n as integer, end of n as integer}
			if ((n1 is 0) or (n2 is 0)) then error
		end if
	on error
		error "filterList:forInstance:ofClass:|where|: : bad forInstance: parameter." number -1700 from n
	end try
	
	-- Check that the class parameter's a class.
	if (requiredClass's class is not class) then error "filterList:forInstance:ofClass:|where|: : the ofClass: parameter isn't a class." number -1700 from requiredClass to class
	
	-- Analyse the 'where' parameter.
	set classOfThePredicate to thePredicate's class
	try
		if (classOfThePredicate is script) then
			-- If it's a script object, great.
			set matcher to thePredicate
		else if (classOfThePredicate is text) then
			-- If it's a line of text, insert it into the source code for a script to create a script object and run the code.
			set matcher to (run script ¬
				("on run
				script
					on isMatch(a)
						tell a to return (" & thePredicate & ")
					end
				end
				return result
			end"))
		else if (classOfThePredicate is list) then
			-- If it's a list of texts and/or {text, value} lists, parse its contents to use in the source code for a parametered script to create a script object.
			set insertCode to ""
			set argv to {}
			set argvIndex to 0
			repeat with i from 1 to (count thePredicate)
				set thisFragment to item i of thePredicate
				set classOfThisFragment to thisFragment's class
				if (classOfThisFragment is text) then
					-- Text. Append to the insert code as is.
					set insertCode to insertCode & " " & thisFragment
				else if ((classOfThisFragment is list) and ((count thisFragment) is 2)) then
					-- Two-item list. If its first item's text, append that to the insert code along with a reference to the next position in the parameter list and add the second item to that list.
					set fragmentCode to beginning of thisFragment
					if (fragmentCode's class is not text) then error
					set argvIndex to argvIndex + 1
					set insertCode to insertCode & (" (" & fragmentCode & " argv's item " & argvIndex & ")")
					set end of argv to end of thisFragment
				else
					error
				end if
			end repeat
			-- Complete the source code and run it to set up and obtain the script object.
			set matcher to (run script ¬
				("on run argv
				script
					on isMatch(a)
						tell a to return (" & insertCode & ")
					end
				end
				return result
			end") ¬
					with parameters argv)
		else
			error
		end if
	on error
		error "filterList:forInstance:ofClass:|where|: : bad |where|: parameter." number -1700 from thePredicate
	end try
	
	-- Work through the source list, testing each item against the specified conditions and dealing with it accordingly.
	set matchCount to 0
	repeat with i from 1 to (count o's sourceList)
		set thisItem to item i of o's sourceList
		set classOfThisItem to thisItem's class
		-- Class membership is satisfied by an exact match to the required class, the required class being item, or the required class being number and the item's class being either integer or real.
		-- A predicate match is indicated by matcher's isMatch(thisItem) returning true.
		if (((classOfThisItem is requiredClass) or (requiredClass is item) or ((requiredClass is number) and ((classOfThisItem is integer) or (classOfThisItem is real)))) and (matcher's isMatch(thisItem))) then
			set matchCount to matchCount + 1
			if (singleMatchWanted) then
				-- If only the nth match is wanted, simply return it when shows up.
				if (matchCount = n) then return thisItem
			else
				-- Otherwise add all matches to the matched items list.
				set end of o's matchedItems to thisItem
			end if
		end if
	end repeat
	-- If we get this far, a single match wasn't returned above.
	if (singleMatchWanted) then
		-- Oops.
		error "filterList:forInstance:ofClass:|where|: : Can't get match " & n & " from " & matchCount & " matches." number -1728
	else if (n is {}) then
		-- Return every match, or the empty list if none.
		return o's matchedItems
	else
		-- Try to return the requested range of matched items.
		try
			return items n1 thru n2 of o's matchedItems
		on error
			error "filterList:forInstance:ofClass:|where|: : Can't get matches " & n1 & " thru " & n2 & " from " & matchCount & " matches." number -1728 partial result (get o's matchedItems)
		end try
	end if
end filterList:forInstance:ofClass:|where|:

NIgel,

All I can say is WOW!!! What a tremendous tool that we sorely needed!!!

Thanks for sharing.

Thanks also for the examples on how to use your tool.
I was wondering if you happen to have handy the source data to use with these examples?
If so could you please post?

But please don’t go to any extra effort at this point to create them. If you don’t have them I’ll be glad to create them (or try to) and share with all.

Thanks again.
JMichaelTX

Hi JMichaelTX.

Lovely graphic! :slight_smile:

Glad you like the script. I wrote it a couple of years ago — just for the challenge, I think — posted it, and promptly forgot all about it. It’s been quite a shock looking at it again this morning!

I don’t have the data for the examples above. I may have just made them up for illustrative purposes. But I do have the following at the bottom of my own copy of the script, which I hope will be helpful.

(* Demos: *)
--(*
set mixedList to {"no", 72, {-21, 7, 41, -2, -18, 14, 15, 43, -50, 49}, 172.0, {"wind", "party", "has", "brown", "long", "long", "good", "the", "time", "good"}, {a:289, b:missing value, c:170.0}, "dog", -397, {27, -1, 34, 19, -38, -29}, 437.0, {"wind", "down", "time", "no", "to", "to"}, {a:469, b:missing value, c:960.0}, "brown", -303, {-2, 25, 50, -28, -19, 7}, -106.0, {"nobody", "lining", "dog", "the", "for", "for"}, {a:997, b:missing value, c:882.0}, "long", -275, {-5, -3, 20, -16, -4, 3, 5, 31, -21, -2}, -46.0, {"has", "brown", "nobody", "men", "all", "quick", "of", "men", "the", "brown"}, {a:520, b:missing value, c:239.0}, "long", -288, {23}, 389.0, {"time"}, {a:49, b:"down", c:884.0}, "is", 380, {0, 26, -50}, 484.0, {"is", "blows", "has"}, {a:597, b:missing value, c:351.0}, "jumps", -334, {-44, 8}, -488.0, {"the", "down"}, {a:314, b:"the", c:241.0}, "wind", 114, {-30, 41, -8, -9, 17, -50, 18, -27}, -435.0, {"blows", "party", "lazy", "fox", "down", "good", "it's", "which"}, {a:904, b:missing value, c:374.0}, "wind", -144, {-17, -32, 36}, 43.0, {"wind", "is", "aid"}, {a:111, b:"which", c:918.0}, "party", 499, {3, 35, 18, -22, -22, -41}, -385.0, {"ill", "brown", "to", "the", "to", "has"}, {a:678, b:"quick", c:884.0}, "quick", 364, {40, 0, -24, -32, 29, 50, 33}, -285.0, {"aid", "now", "silver", "party", "wind", "over", "wind"}, {a:105, b:"time", c:237.0}, "the", 352, {45, -22, -30, -16, -48, -5, 9, 8, 4}, -300.0, {"is", "brown", "the", "quick", "come", "nobody", "all", "the", "lane"}, {a:89, b:"long", c:368.0}}

-- list 1 of mixedList whose length > 6 and it contains "the"
my filterList:mixedList forInstance:1 ofClass:(list) |where|:"its length > 6 and it contains \"the\""
--> {"wind", "party", "has", "brown", "long", "long", "good", "the", "time", "good"}
-- Ditto.
my filterList:mixedList forInstance:1 ofClass:(list) |where|:{{"its length >", 6}, "and", {"it contains", "the"}}
--> {"wind", "party", "has", "brown", "long", "long", "good", "the", "time", "good"}
-- Ditto.
script myPredicate
	on isMatch(a)
		return ((a's length > 6) and (a contains "the"))
	end isMatch
end script
my filterList:mixedList forInstance:1 ofClass:(list) |where|:myPredicate
--> {"wind", "party", "has", "brown", "long", "long", "good", "the", "time", "good"}

-- last text of mixedList whose first character < "m".
set thisCharacter to "m"
my filterList:mixedList forInstance:-1 ofClass:(text) |where|:{{"its first character <", thisCharacter}}
--> "jumps"

-- text 1 thru 3 of mixedList ditto.
my filterList:mixedList forInstance:{1, 3} ofClass:(text) |where|:{{"its first character <", thisCharacter}}
--> {"dog", "brown", "long"}

-- every record of mixedList whose b is not missing value
my filterList:mixedList forInstance:{} ofClass:(record) |where|:{{"its b ≠", missing value}}
--> {{a:49, b:"down", c:884.0}, {a:314, b:"the", c:241.0}, {a:111, b:"which", c:918.0}, {a:678, b:"quick", c:884.0}, {a:105, b:"time", c:237.0}, {a:89, b:"long", c:368.0}}

-- every number of mixedList whose class is real and it > 300.00 or its class is integer and it < -300
my filterList:mixedList forInstance:{} ofClass:(number) |where|:"its class is real and it > 300.0 or its class is integer and it < -300"
--> {-397, 437.0, -303, 389.0, 484.0, -334}
--*)